diff --git a/.changes/xtream-sparse-vod-playback.md b/.changes/xtream-sparse-vod-playback.md new file mode 100644 index 000000000..dd9239630 --- /dev/null +++ b/.changes/xtream-sparse-vod-playback.md @@ -0,0 +1,7 @@ +--- +type: fix +area: xtream +--- + +Xtream movies remain playable when a portal omits extended VOD metadata, +using catalog stream details to build the playback URL. diff --git a/AGENTS.md b/AGENTS.md index 3d24958a1..4fa10bf23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. -- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. +- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. diff --git a/CLAUDE.md b/CLAUDE.md index 56fff708f..9368ea24f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,14 +219,32 @@ nx lint electron-backend CI lints affected projects on PRs (`nx affected`) and every project on master pushes (`.github/workflows/ci.yml`). This enforces the Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint -rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files -are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline -with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. -Never add new files to the baseline — the list must only shrink. A new file -that genuinely cannot be split (for example a function serialized into another -process) instead carries its own file-wide +rule. The limits and their rationale live in one place, +`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the +baseline generator import so the enforced rule and the generated list cannot +drift: + +- **Production TypeScript: hard maximum 400 lines.** +- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under + `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one + at the production limit yields arbitrary `-2.spec.ts` files, and length there + signals coverage rather than the design debt the production limit catches. +- **Blank lines and comments are not counted** (`skipBlankLines`, + `skipComments`), so a docblock is never the reason a file must be split. + +Pre-existing oversized files are baselined in +`tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with +`node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The +generator decides who belongs on the list by running ESLint's own `max-lines` +rule, not by counting lines itself — a private reimplementation would silently +disagree with the rule and produce a baseline that turns CI red while looking +correct. Never add new files to the baseline — the list must only shrink. A new +file that genuinely cannot be split (for example a function serialized into +another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so -a justified exemption never lands in the baseline. +a justified exemption never lands in the baseline. If such a directive later +becomes unnecessary, ESLint reports it as an unused disable directive — remove +it rather than leaving a stale justification behind. Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX @@ -490,7 +508,11 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. **TypeScript File Size Rule**: -Keep TypeScript files under **300 lines**. Hard maximum is **350–400 lines**. +Keep production TypeScript files under **300 lines**. Hard maximum is +**350–400 lines**, and CI enforces the 400. Blank lines and comments do not +count toward it, so documenting a file never costs you headroom. Tests +(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the +guidance below is about production code. - When creating new files, design them to stay within this limit from the start. - When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code. @@ -673,12 +695,12 @@ with its own `mimeType`. Electron Builder derives all three platform registrations from it: macOS `CFBundleDocumentTypes` (which is what makes `open-file` fire from Finder), the NSIS registry entries, and, on Linux, the desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for -deb/rpm/pacman. Two traps: it assigns the derived `MimeType` *after* spreading +deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading `linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and must not be used; and it appends `%U` to `Exec`, so Linux file managers hand over percent-encoded `file://` URIs rather than paths — `createPlaylistOpenRequest` decodes them before the extension check. `%U` is -also the *plural* exec code, so a multi-file selection arrives as one launch +also the _plural_ exec code, so a multi-file selection arrives as one launch with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all of them and `enqueueAll` queues the batch, because stopping at the first match would silently drop the rest of the selection. Adding an exec code to @@ -862,6 +884,7 @@ engine` (restart required) or - The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style) - For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill - Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`) +- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields. - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again. - Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block diff --git a/apps/electron-backend-e2e/src/electron-process-lifecycle.ts b/apps/electron-backend-e2e/src/electron-process-lifecycle.ts new file mode 100644 index 000000000..51841fcd1 --- /dev/null +++ b/apps/electron-backend-e2e/src/electron-process-lifecycle.ts @@ -0,0 +1,171 @@ +import type { ChildProcess } from 'node:child_process'; + +type ElectronChildProcess = Pick< + ChildProcess, + 'exitCode' | 'kill' | 'once' | 'removeListener' | 'signalCode' +>; + +export interface ClosableElectronApplication { + close(): Promise; + process(): ElectronChildProcess; +} + +export interface ElectronExitConfirmationOptions { + readonly closeTimeoutMs: number; + readonly exitTimeoutMs: number; +} + +export interface PrepareElectronApplicationOptions { + readonly application: Application; + readonly dispose: (application: Application) => Promise; + readonly prepare: (application: Application) => Promise; +} + +export class ElectronApplicationDisposalError extends AggregateError { + constructor(failure: unknown, disposeFailure: unknown) { + super( + [failure, disposeFailure], + 'Electron launch preparation and disposal both failed', + { cause: failure } + ); + this.name = 'ElectronApplicationDisposalError'; + } +} + +export async function prepareElectronApplication( + options: PrepareElectronApplicationOptions +): Promise { + try { + return await options.prepare(options.application); + } catch (failure) { + try { + await options.dispose(options.application); + } catch (disposeFailure) { + throw new ElectronApplicationDisposalError(failure, disposeFailure); + } + throw failure; + } +} + +export async function closeElectronApplicationAndConfirmExit( + application: ClosableElectronApplication, + options: ElectronExitConfirmationOptions +): Promise { + assertTimeout(options.closeTimeoutMs); + assertTimeout(options.exitTimeoutMs); + const child = application.process(); + if (hasExited(child)) return; + + const exit = observeProcessExit(child); + let failure: unknown; + try { + const close = Promise.resolve() + .then(() => application.close()) + .then( + () => ({ kind: 'close-resolved' as const }), + (closeFailure: unknown) => ({ + failure: closeFailure, + kind: 'close-rejected' as const, + }) + ); + const first = await raceCloseAndExit( + close, + exit.promise, + options.closeTimeoutMs + ); + if (first.kind === 'exit') return; + if ( + first.kind === 'close-resolved' && + (await resolvesWithin(exit.promise, options.exitTimeoutMs)) + ) { + return; + } + if (first.kind === 'close-rejected') failure = first.failure; + + try { + child.kill(); + } catch (killFailure) { + failure = failure + ? new AggregateError([failure, killFailure]) + : killFailure; + } + if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) return; + } finally { + exit.cancel(); + } + throw new Error('electron-process-exit-unconfirmed', { cause: failure }); +} + +function hasExited(child: ElectronChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function observeProcessExit(child: ElectronChildProcess): { + readonly cancel: () => void; + readonly promise: Promise; +} { + let resolveExit: (() => void) | undefined; + const onExit = () => resolveExit?.(); + const promise = new Promise((resolvePromise) => { + resolveExit = resolvePromise; + child.once('exit', onExit); + if (hasExited(child)) resolvePromise(); + }); + return { + cancel: () => child.removeListener('exit', onExit), + promise, + }; +} + +async function resolvesWithin( + promise: Promise, + timeoutMs: number +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolvePromise) => { + timeout = setTimeout(() => resolvePromise(false), timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function raceCloseAndExit( + close: Promise< + | { readonly kind: 'close-resolved' } + | { readonly failure: unknown; readonly kind: 'close-rejected' } + >, + exit: Promise, + timeoutMs: number +): Promise< + | { readonly kind: 'close-resolved' } + | { readonly failure: unknown; readonly kind: 'close-rejected' } + | { readonly kind: 'exit' } + | { readonly kind: 'timeout' } +> { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + close, + exit.then(() => ({ kind: 'exit' as const })), + new Promise<{ readonly kind: 'timeout' }>((resolvePromise) => { + timeout = setTimeout( + () => resolvePromise({ kind: 'timeout' }), + timeoutMs + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function assertTimeout(value: number): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('electron-process-exit-timeout-invalid'); + } +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 920427dcf..818546505 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -27,6 +27,10 @@ import { reapOrphanedDataDirs, writeDataDirOwnerMarker, } from './data-dir-reaper'; +import { + closeElectronApplicationAndConfirmExit, + prepareElectronApplication, +} from './electron-process-lifecycle'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -223,18 +227,26 @@ export async function launchElectronApp( args, env: buildElectronLaunchEnvironment(dataDir, options), }); - attachElectronProcessDiagnostics(electronApp); - - const mainWindow = await findMainWindow(electronApp); - await waitForAppReady(mainWindow); - await startPortalDebugCapture(mainWindow); - await startDbOperationCapture(mainWindow); - await startRendererFrameCapture(mainWindow); - - return { - electronApp, - mainWindow, - }; + return prepareElectronApplication({ + application: electronApp, + dispose: (application) => + closeElectronApplicationAndConfirmExit(application, { + closeTimeoutMs: electronAppCloseTimeoutMs, + exitTimeoutMs: electronAppKillWaitMs, + }), + prepare: async (application) => { + attachElectronProcessDiagnostics(application); + const mainWindow = await findMainWindow(application); + await waitForAppReady(mainWindow); + await startPortalDebugCapture(mainWindow); + await startDbOperationCapture(mainWindow); + await startRendererFrameCapture(mainWindow); + return { + electronApp: application, + mainWindow, + }; + }, + }); } export function buildElectronLaunchEnvironment( @@ -495,15 +507,19 @@ export async function launchCompetingElectronInstance( const { appArgs = [], timeoutMs = 30000 } = options; // In a Node context the `electron` package resolves to its binary path. const electronBinaryPath = require('electron') as unknown as string; - const child = spawn(electronBinaryPath, buildElectronLaunchArgs([], appArgs), { - env: { - ...process.env, - ELECTRON_IS_DEV: '0', - IPTVNATOR_E2E_DATA_DIR: dataDir, - NODE_ENV: 'test', - }, - stdio: ['ignore', 'ignore', 'pipe'], - }); + const child = spawn( + electronBinaryPath, + buildElectronLaunchArgs([], appArgs), + { + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + stdio: ['ignore', 'ignore', 'pipe'], + } + ); // Kept for the assertion message: a competing launch that dies for an // unrelated reason (missing sandbox, missing GPU) looks exactly like a @@ -570,6 +586,15 @@ export async function closeElectronApp( } } +export async function closeElectronAppAndConfirmExit( + app: LaunchedElectronApp +): Promise { + await closeElectronApplicationAndConfirmExit(app.electronApp, { + closeTimeoutMs: electronAppCloseTimeoutMs, + exitTimeoutMs: electronAppKillWaitMs, + }); +} + async function waitForPromiseWithTimeout( promise: Promise, timeoutMs: number diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts index 7ddf4e4cf..39802115d 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts @@ -1,5 +1,4 @@ /* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ -/* eslint-disable max-lines -- The one-shot transport contract keeps all terminal-path fixtures together. */ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import test from 'node:test'; diff --git a/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts index c6c82239f..4ab626254 100644 --- a/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts +++ b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, it } from 'node:test'; import { @@ -8,6 +10,33 @@ import { import { assertXtreamBenchmarkEnvironmentIsolated } from './xtream-benchmark-iteration-support'; describe('Electron launch environment isolation', () => { + it('makes post-spawn fixture preparation exception-safe', () => { + const fixture = readFileSync( + resolve(process.cwd(), 'src/electron-test-fixtures.ts'), + 'utf8' + ); + const launch = fixture.indexOf( + 'const electronApp = await electron.launch' + ); + const guardedPreparation = fixture.indexOf( + 'return prepareElectronApplication', + launch + ); + const strictDisposal = fixture.indexOf( + 'closeElectronApplicationAndConfirmExit', + guardedPreparation + ); + const mainWindow = fixture.indexOf( + 'const mainWindow = await findMainWindow', + guardedPreparation + ); + + assert.ok(launch >= 0); + assert.ok(guardedPreparation > launch); + assert.ok(strictDisposal > guardedPreparation); + assert.ok(mainWindow > strictDisposal); + }); + it('inherits only portable runtime keys for a profiled benchmark child', () => { const environment = buildElectronLaunchEnvironment( '/tmp/iptvnator-performance', diff --git a/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts new file mode 100644 index 000000000..15a6d003b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; + +import { + closeElectronApplicationAndConfirmExit, + ElectronApplicationDisposalError, + prepareElectronApplication, +} from '../electron-process-lifecycle'; + +class FakeChildProcess extends EventEmitter { + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killCalls = 0; + shouldExitOnKill = true; + + kill(): boolean { + this.killCalls += 1; + if (this.shouldExitOnKill) { + this.signalCode = 'SIGTERM'; + queueMicrotask(() => this.emit('exit', null, 'SIGTERM')); + } + return true; + } +} + +describe('Electron process lifecycle', () => { + it('closes and confirms exit when post-spawn launch preparation fails', async () => { + const child = new FakeChildProcess(); + const launchFailure = new Error('renderer readiness failed'); + let closeCalls = 0; + const application = { + close: async () => { + closeCalls += 1; + child.exitCode = 0; + child.emit('exit', 0, null); + }, + process: () => child, + }; + + await assert.rejects( + prepareElectronApplication({ + application, + dispose: (app) => + closeElectronApplicationAndConfirmExit(app, { + closeTimeoutMs: 10, + exitTimeoutMs: 10, + }), + prepare: async () => { + throw launchFailure; + }, + }), + (error: unknown) => error === launchFailure + ); + assert.equal(closeCalls, 1); + assert.equal(child.exitCode, 0); + }); + + it('fails when process exit remains unconfirmed after forced teardown', async () => { + const child = new FakeChildProcess(); + child.shouldExitOnKill = false; + const application = { + close: () => new Promise(() => undefined), + process: () => child, + }; + + await assert.rejects( + closeElectronApplicationAndConfirmExit(application, { + closeTimeoutMs: 1, + exitTimeoutMs: 1, + }), + /electron-process-exit-unconfirmed/ + ); + assert.equal(child.killCalls, 1); + }); + + it('preserves both preparation and unconfirmed-disposal failures', async () => { + const preparationFailure = new Error('renderer readiness failed'); + const disposalFailure = new Error('electron-process-exit-unconfirmed'); + + await assert.rejects( + prepareElectronApplication({ + application: {}, + dispose: async () => { + throw disposalFailure; + }, + prepare: async () => { + throw preparationFailure; + }, + }), + (error: unknown) => + error instanceof ElectronApplicationDisposalError && + error.cause === preparationFailure && + error.errors.includes(disposalFailure) + ); + }); + + it('observes process exit promptly even when Playwright close never settles', async () => { + const child = new FakeChildProcess(); + const application = { + close: () => { + queueMicrotask(() => { + child.exitCode = 0; + child.emit('exit', 0, null); + }); + return new Promise(() => undefined); + }, + process: () => child, + }; + + await Promise.race([ + closeElectronApplicationAndConfirmExit(application, { + closeTimeoutMs: 1_000, + exitTimeoutMs: 10, + }), + new Promise((_, rejectPromise) => { + setTimeout( + () => rejectPromise(new Error('exit-observation-delayed')), + 50 + ); + }), + ]); + assert.equal(child.killCalls, 0); + }); + + it('confirms the exit caused by forced teardown', async () => { + const child = new FakeChildProcess(); + const application = { + close: () => new Promise(() => undefined), + process: () => child, + }; + + await closeElectronApplicationAndConfirmExit(application, { + closeTimeoutMs: 1, + exitTimeoutMs: 10, + }); + assert.equal(child.killCalls, 1); + assert.equal(child.signalCode, 'SIGTERM'); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts index e00b7e637..8bce4efe2 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */ import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; import type { M3uImportBenchmarkManifest } from './m3u-import-summary'; 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 index f95d075e6..3a296d51e 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */ import type { CancellationBenchmarkManifest, CancellationBenchmarkSummary, 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 index 09b7f1abf..bcb95d21d 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -1,4 +1,3 @@ -/* 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'; 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 index 2c574b3aa..e61c1e6c9 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ import { createWriteStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-app-startup.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-app-startup.ts new file mode 100644 index 000000000..71620578f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-app-startup.ts @@ -0,0 +1,168 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + closeElectronAppAndConfirmExit, + launchElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { ElectronApplicationDisposalError } from '../electron-process-lifecycle'; +import type { XtreamBenchmarkConfiguration } from './xtream-benchmark-configuration'; +import { + installMainCapture, + type MainCaptureStartOptions, +} from './m3u-refresh-main-capture'; +import { + assertRendererCdpTarget, + assertTcpPortAvailable, + resolveRendererWindowIdentity, +} from './m3u-import-benchmark-support'; +import { + assertXtreamBenchmarkEnvironmentIsolated, + prearmXtreamDatabaseWorker, +} from './xtream-benchmark-iteration-support'; +import { disableGenericElectronFixtureProbes } from './xtream-benchmark-runtime'; +import { + captureXtreamLaunchIdentity, + type XtreamLaunchIdentity, +} from './xtream-process-evidence'; +import { + classifyXtreamStartupFailure, + runXtreamStartupWithRetry, +} from './xtream-startup-retry'; +import type { XtreamStartupRetryReason } from './xtream-summary-contract'; + +interface XtreamAppResource { + readonly app: LaunchedElectronApp; + readonly dataDirectory: string; +} + +interface PreparedXtreamApp { + readonly launch: XtreamLaunchIdentity; + readonly rendererWindowIdentity: MainCaptureStartOptions['rendererWindowIdentity']; +} + +export interface LaunchedXtreamBenchmarkApp + extends XtreamAppResource, PreparedXtreamApp { + readonly startupEvidence: { + readonly attemptCount: number; + readonly retryReasons: readonly XtreamStartupRetryReason[]; + readonly status: 'ready'; + }; +} + +export interface LaunchXtreamBenchmarkAppOptions { + readonly configuration: XtreamBenchmarkConfiguration; + readonly seenElectronPids: Set; +} + +async function disposeXtreamAppResource( + resource: XtreamAppResource +): Promise { + await closeElectronAppAndConfirmExit(resource.app); + await removeXtreamDataDirectory(resource.dataDirectory); +} + +async function createXtreamAppResource( + configuration: XtreamBenchmarkConfiguration +): Promise { + await assertTcpPortAvailable(configuration.cdpPort); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-performance-') + ); + try { + const app = await launchElectronApp(dataDirectory, { + args: [ + '--js-flags=--expose-gc', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${configuration.cdpPort}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + environmentInheritance: 'runtime-only', + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: '0', + }, + omitEnvKeys: [ + 'IPTVNATOR_XTREAM_MOCK_CONTROL', + 'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN', + ], + }); + return { app, dataDirectory }; + } catch (failure) { + if (failure instanceof ElectronApplicationDisposalError) { + throw failure; + } + try { + await removeXtreamDataDirectory(dataDirectory); + } catch (cleanupFailure) { + throw new AggregateError( + [failure, cleanupFailure], + 'Xtream app launch and profile cleanup both failed', + { cause: failure } + ); + } + throw failure; + } +} + +function removeXtreamDataDirectory(dataDirectory: string): Promise { + return rm(dataDirectory, { + force: true, + maxRetries: 20, + recursive: true, + retryDelay: 250, + }); +} + +async function prepareXtreamAppResource( + resource: XtreamAppResource, + options: LaunchXtreamBenchmarkAppOptions +): Promise { + const { app } = resource; + const { configuration } = options; + app.mainWindow.setDefaultTimeout(10 * 60 * 1_000); + await assertXtreamBenchmarkEnvironmentIsolated(app); + await disableGenericElectronFixtureProbes(app.mainWindow); + await assertRendererCdpTarget(app.mainWindow, configuration.cdpPort); + await installMainCapture(app.electronApp); + await prearmXtreamDatabaseWorker(app); + const launch = await captureXtreamLaunchIdentity( + app.electronApp, + app.electronApp.process().pid, + options.seenElectronPids + ); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + return { launch, rendererWindowIdentity }; +} + +export async function launchXtreamBenchmarkApp( + options: LaunchXtreamBenchmarkAppOptions +): Promise { + const started = await runXtreamStartupWithRetry({ + classifyFailure: classifyXtreamStartupFailure, + create: () => createXtreamAppResource(options.configuration), + dispose: disposeXtreamAppResource, + maxAttempts: 2, + prepare: (resource) => prepareXtreamAppResource(resource, options), + }); + return Object.freeze({ + ...started.resource, + ...started.prepared, + startupEvidence: Object.freeze({ + attemptCount: started.attemptCount, + retryReasons: started.retryReasons, + status: 'ready' as const, + }), + }); +} + +export async function disposeXtreamBenchmarkApp( + app: LaunchedElectronApp, + dataDirectory: string +): Promise { + await disposeXtreamAppResource({ app, dataDirectory }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts index 45d83ea69..03868de83 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts @@ -1,12 +1,11 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; +import type { LaunchedElectronApp } from '../electron-test-fixtures'; import { - closeElectronApp, - launchElectronApp, - type LaunchedElectronApp, -} from '../electron-test-fixtures'; + disposeXtreamBenchmarkApp, + launchXtreamBenchmarkApp, +} from './xtream-benchmark-app-startup'; import { type XtreamIterationDefinition, type XtreamPerformanceManifest, @@ -24,18 +23,12 @@ import { readXtreamCatalogDatabaseSnapshot } from './xtream-catalog-database-rea import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; import { beginXtreamMainMeasurement, - installMainCapture, readXtreamMainCaptureStatus, rolloverXtreamMainCapture, startMainCapture, stopXtreamMainCapture, type MainCaptureStartOptions, } from './m3u-refresh-main-capture'; -import { - assertRendererCdpTarget, - assertTcpPortAvailable, - resolveRendererWindowIdentity, -} from './m3u-import-benchmark-support'; import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; import { createXtreamArtifactSafetyContext, @@ -46,25 +39,21 @@ import { import { createXtreamSingleAttemptCapture, persistXtreamIterationFailureEvidenceAndThrow, + runXtreamFinalTeardown, type XtreamBenchmarkFailureStage, + type XtreamPropagatedFailure, } from './xtream-benchmark-lifecycle'; import { - assertXtreamBenchmarkEnvironmentIsolated, createXtreamDiagnosticEvidence, createXtreamSourceTitle, finalizeXtreamCaptureAfterRendererTerminal, - prearmXtreamDatabaseWorker, seedXtreamExistingPortal, } from './xtream-benchmark-iteration-support'; import { deriveXtreamMeasuredPlaylistId, - disableGenericElectronFixtureProbes, xtreamScenarioRequiresSeed, } from './xtream-benchmark-runtime'; -import { - captureXtreamLaunchIdentity, - createXtreamIterationProcessEvidence, -} from './xtream-process-evidence'; +import { createXtreamIterationProcessEvidence } from './xtream-process-evidence'; import { startXtreamRendererCapture, type RunningXtreamRendererCapture, @@ -95,13 +84,12 @@ export async function runXtreamBenchmarkIteration( mode: 0o700, recursive: false, }); - const dataDirectory = await mkdtemp( - join(tmpdir(), 'iptvnator-xtream-performance-') - ); const safety = createXtreamArtifactSafetyContext( configuration.controlToken ); let app: LaunchedElectronApp | null = null; + let dataDirectory: string | null = null; + let propagatedFailure: XtreamPropagatedFailure | null = null; let renderer: RunningXtreamRendererCapture | null = null; let prepared: PreparedXtreamScenario | null = null; let controlState: Awaited> | null = @@ -117,44 +105,23 @@ export async function runXtreamBenchmarkIteration( await control.resetAll(); const fixture = await control.prepare(); assertXtreamManifestIdentity(context.expectedFixture, fixture); - await assertTcpPortAvailable(configuration.cdpPort); - app = await launchElectronApp(dataDirectory, { - args: [ - '--js-flags=--expose-gc', - '--remote-debugging-address=127.0.0.1', - `--remote-debugging-port=${configuration.cdpPort}`, - `--user-data-dir=${join(dataDirectory, 'user-data')}`, - ], - environmentInheritance: 'runtime-only', - env: { - IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', - IPTVNATOR_PERF_CAPTURE: '1', - IPTVNATOR_PERF_WORKER_PROFILING: '1', - IPTVNATOR_TRACE_RENDERER_CONSOLE: '0', - }, - omitEnvKeys: [ - 'IPTVNATOR_XTREAM_MOCK_CONTROL', - 'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN', - ], + const started = await launchXtreamBenchmarkApp({ + configuration, + seenElectronPids: context.seenElectronPids, }); - app.mainWindow.setDefaultTimeout(10 * 60 * 1_000); - await assertXtreamBenchmarkEnvironmentIsolated(app); - await disableGenericElectronFixtureProbes(app.mainWindow); - await assertRendererCdpTarget(app.mainWindow, configuration.cdpPort); - await installMainCapture(app.electronApp); - await prearmXtreamDatabaseWorker(app); - const electronApp = app.electronApp; - const launch = await captureXtreamLaunchIdentity( - app.electronApp, - app.electronApp.process().pid, - context.seenElectronPids + app = started.app; + dataDirectory = started.dataDirectory; + await writeXtreamSafeJson( + join(iterationDirectory, 'startup-evidence.json'), + started.startupEvidence, + safety ); - const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + const electronApp = app.electronApp; const captureOptions: MainCaptureStartOptions = { deferMeasurement: true, diagnostic: definition.kind === 'diagnostic', outputDirectory: iterationDirectory, - rendererWindowIdentity, + rendererWindowIdentity: started.rendererWindowIdentity, }; const sourceTitle = createXtreamSourceTitle(definition); const seeded = xtreamScenarioRequiresSeed(definition.scenarioId); @@ -260,7 +227,7 @@ export async function runXtreamBenchmarkIteration( const finalized = await finalizeXtreamCaptureAfterRendererTerminal({ captureGeneration, driverTerminal: terminalPromise, - readStatus: () => readXtreamMainCaptureStatus(app.electronApp), + readStatus: () => readXtreamMainCaptureStatus(electronApp), rendererTerminal: renderer.waitForTerminal(), scenarioId: definition.scenarioId, stopMain: stopMainCaptureOnce, @@ -313,7 +280,7 @@ export async function runXtreamBenchmarkIteration( app.electronApp, { captureStoppedEpochMs: mainCapture.captureStoppedEpochMs, - dataDirectory, + dataDirectory: started.dataDirectory, playlistId, requireFromPath: join( configuration.workspaceRoot, @@ -338,10 +305,12 @@ export async function runXtreamBenchmarkIteration( iterationDirectory ); const processEvidence = await createXtreamIterationProcessEvidence({ - ...launch, + ...started.launch, diagnosticDirectorySha256: diagnosticEvidence?.directorySha256 ?? null, iterationDirectory, + startupAttemptCount: started.startupEvidence.attemptCount, + startupRetryReasons: started.startupEvidence.retryReasons, }); const raw = assembleXtreamRawIteration({ catalogVerification, @@ -362,38 +331,48 @@ export async function runXtreamBenchmarkIteration( ); } catch (failure) { const electronApp = app?.electronApp; - await persistXtreamIterationFailureEvidenceAndThrow(failure, { - evidence: { - control: async () => controlState ?? control.state(), - ...(electronApp - ? { - mainStatus: () => - readXtreamMainCaptureStatus(electronApp), - } - : {}), - ...(stopMainCaptureOnce - ? { mainCapture: stopMainCaptureOnce } - : {}), - ...(stopRendererCaptureOnce - ? { rendererCapture: stopRendererCaptureOnce } - : {}), - ...(terminal ? { terminal: async () => terminal } : {}), - ...(trigger ? { trigger: async () => trigger } : {}), - }, - persist: (filename, value) => - writeXtreamSafeJson( - join(iterationDirectory, filename), - value, - safety - ), - stage: failureStage, - }); + try { + return await persistXtreamIterationFailureEvidenceAndThrow( + failure, + { + evidence: { + control: async () => controlState ?? control.state(), + ...(electronApp + ? { + mainStatus: () => + readXtreamMainCaptureStatus(electronApp), + } + : {}), + ...(stopMainCaptureOnce + ? { mainCapture: stopMainCaptureOnce } + : {}), + ...(stopRendererCaptureOnce + ? { rendererCapture: stopRendererCaptureOnce } + : {}), + ...(terminal ? { terminal: async () => terminal } : {}), + ...(trigger ? { trigger: async () => trigger } : {}), + }, + persist: (filename, value) => + writeXtreamSafeJson( + join(iterationDirectory, filename), + value, + safety + ), + stage: failureStage, + } + ); + } catch (failureToPropagate) { + propagatedFailure = { failure: failureToPropagate }; + throw failureToPropagate; + } } finally { await prepared?.dispose().catch(() => undefined); await renderer?.dispose().catch(() => undefined); - if (app) await closeElectronApp(app); - await rm(dataDirectory, { force: true, recursive: true }).catch( - () => undefined - ); + if (app && dataDirectory) { + await runXtreamFinalTeardown( + () => disposeXtreamBenchmarkApp(app, dataDirectory), + propagatedFailure + ); + } } } diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts index be7038c6d..ffa3e8749 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts @@ -7,6 +7,7 @@ import { createXtreamSingleAttemptCapture, persistXtreamIterationFailureEvidence, persistXtreamIterationFailureEvidenceAndThrow, + runXtreamFinalTeardown, } from './xtream-benchmark-lifecycle'; const performanceDirectory = resolve(process.cwd(), 'src/performance'); @@ -190,6 +191,40 @@ describe('Xtream benchmark failure lifecycle', () => { ); }); + it('preserves both the propagated iteration and final teardown failures', async () => { + const iterationFailure = new Error('fixed-iteration-failure'); + const teardownFailure = new Error('fixed-teardown-failure'); + + await assert.rejects( + runXtreamFinalTeardown( + async () => { + throw teardownFailure; + }, + { failure: iterationFailure } + ), + (failure: unknown) => { + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [ + iterationFailure, + teardownFailure, + ]); + assert.strictEqual(failure.cause, iterationFailure); + return true; + } + ); + }); + + it('preserves an exact teardown failure when the iteration succeeded', async () => { + const teardownFailure = new Error('fixed-teardown-failure'); + + await assert.rejects( + runXtreamFinalTeardown(async () => { + throw teardownFailure; + }, null), + (failure: unknown) => failure === teardownFailure + ); + }); + it('collects failure evidence before teardown and writes summary before its guard', () => { const iteration = readFileSync( resolve(performanceDirectory, 'xtream-benchmark-iteration.ts'), @@ -201,6 +236,10 @@ describe('Xtream benchmark failure lifecycle', () => { failureCatch ); const teardown = iteration.indexOf('} finally {', failureCatch); + const preservingTeardown = iteration.indexOf( + 'runXtreamFinalTeardown(', + teardown + ); assert.ok(failureCatch >= 0, 'iteration failure catch is missing'); assert.ok( @@ -211,6 +250,10 @@ describe('Xtream benchmark failure lifecycle', () => { teardown > failurePersistence, 'teardown occurs before failure evidence persistence' ); + assert.ok( + preservingTeardown > teardown, + 'final teardown does not preserve an iteration failure' + ); assert.doesNotMatch( iteration.slice(failureCatch, teardown), /if \(captureInstalled\)/u, diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts index d41d35f49..bd8cca6b1 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts @@ -29,6 +29,10 @@ interface PersistXtreamIterationFailureEvidenceOptions { readonly stage: XtreamBenchmarkFailureStage; } +export interface XtreamPropagatedFailure { + readonly failure: unknown; +} + const EVIDENCE_ARTIFACTS: readonly [FailureEvidenceName, string][] = Object.freeze([ ['mainStatus', 'failure-main-status.json'], @@ -91,3 +95,19 @@ export async function persistXtreamIterationFailureEvidenceAndThrow( } throw failure; } + +export async function runXtreamFinalTeardown( + teardown: () => Promise, + propagatedFailure: XtreamPropagatedFailure | null +): Promise { + try { + await teardown(); + } catch (teardownFailure) { + if (!propagatedFailure) throw teardownFailure; + throw new AggregateError( + [propagatedFailure.failure, teardownFailure], + 'Xtream benchmark iteration and final teardown both failed', + { cause: propagatedFailure.failure } + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts index ad770f04d..b4c182e7c 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts @@ -61,12 +61,24 @@ export interface XtreamReportFixture { readonly iterations: readonly XtreamValidatedIterationResult[]; } -export async function formalReportFixture(): Promise { - return reportFixture(createXtreamIterationDefinitions(false), 10, true); +export async function formalReportFixture( + startupRetryIndex: number | null = null +): Promise { + return reportFixture( + createXtreamIterationDefinitions(false), + 10, + true, + startupRetryIndex + ); } export async function smokeReportFixture(): Promise { - return reportFixture(createXtreamIterationDefinitions(true), 100, false); + return reportFixture( + createXtreamIterationDefinitions(true), + 100, + false, + null + ); } export async function clearXtreamProvenanceFixtureDirectories(): Promise { @@ -80,7 +92,8 @@ export async function clearXtreamProvenanceFixtureDirectories(): Promise { async function reportFixture( definitions: readonly XtreamIterationDefinition[], identityOffset: number, - mixedInitialOverlap: boolean + mixedInitialOverlap: boolean, + startupRetryIndex: number | null ): Promise { const entries = await Promise.all( definitions.map(async (definition, index) => { @@ -105,6 +118,16 @@ async function reportFixture( ) { raw = withWorkerRequestOverlap(raw, 2); } + if (index === startupRetryIndex) { + raw = { + ...raw, + processIdentity: { + ...raw.processIdentity, + startupAttemptCount: 2, + startupRetryReasons: ['sqlite-database-locked'], + }, + }; + } return { evidence, iteration: await persist(raw, identity + identityOffset), diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts index 19c6c50f5..0210c31d7 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -33,6 +33,12 @@ import { import { bindFixtureWorkerEvidence } from './xtream-worker-phase-evidence.fixture'; import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +export const sha256 = (value: string): string => + createHash('sha256').update(value).digest('hex'); +const hash = (character: string): string => character.repeat(64); +const numberedHash = (value: number, salt: number): string => + sha256(`${salt}:${value}`); + export const TOKEN = 'random-control-token-that-must-never-persist'; const buildFile = (path: string, marker: string): XtreamBuildFileIdentity => ({ bytes: marker.length, @@ -216,6 +222,8 @@ export function iteration( generationIdentitySha256: numberedHash(identity, 3), launchId: `launch-${identity}`, profileDirectorySha256: numberedHash(identity, 4), + startupAttemptCount: 1, + startupRetryReasons: [], }, phaseCapture, phases, @@ -386,15 +394,3 @@ function restoreValidRequestMetrics( threadCpuUserMicros: 20, }; } - -export function sha256(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -function hash(character: string): string { - return character.repeat(64); -} - -function numberedHash(value: number, salt: number): string { - return sha256(`${salt}:${value}`); -} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts index 9c1aeb0b8..c7336be71 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts @@ -188,6 +188,22 @@ describe('Xtream benchmark report contract', () => { ); }); + it('keeps a bounded startup retry in receipt-bound summary iterations', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(1); + const summary = createXtreamBenchmarkSummary( + MANIFEST, + iterations, + diagnosticEvidence + ); + + const retryIdentity = itemAt(summary.iterations, 1).processIdentity; + assert.equal(retryIdentity.startupAttemptCount, 2); + assert.deepEqual(retryIdentity.startupRetryReasons, [ + 'sqlite-database-locked', + ]); + assert.equal(summary.validForComparison, true); + }); + it('rejects forged validated objects and excludes diagnostic CPU profiles from headline metrics', async () => { const { diagnosticEvidence, iterations } = await formalReportFixture(); const forged = [...iterations]; diff --git a/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts index 9e2882836..1b139526c 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts @@ -8,6 +8,7 @@ import { } from './xtream-benchmark-manifest-schema'; import { XTREAM_NOT_APPLICABLE, + XTREAM_STARTUP_RETRY_REASON, type XtreamRawIterationResult, type XtreamValidatedIterationResult, } from './xtream-summary-contract'; @@ -317,7 +318,17 @@ export function isExactXtreamProcessIdentity(value: unknown): boolean { 'generationIdentitySha256', 'launchId', 'profileDirectorySha256', + 'startupAttemptCount', + 'startupRetryReasons', ] as const; + const startupAttemptCount = isPositiveSafeInteger( + isExactRecord(value, keys) ? value['startupAttemptCount'] : null + ) + ? Number(value['startupAttemptCount']) + : 0; + const startupRetryReasons = isExactRecord(value, keys) + ? value['startupRetryReasons'] + : null; return ( isExactRecord(value, keys) && isPositiveSafeInteger(value['captureGeneration']) && @@ -328,7 +339,17 @@ export function isExactXtreamProcessIdentity(value: unknown): boolean { typeof value['generationIdentitySha256'] === 'string' && XTREAM_SHA256.test(value['generationIdentitySha256']) && typeof value['profileDirectorySha256'] === 'string' && - XTREAM_SHA256.test(value['profileDirectorySha256']) + XTREAM_SHA256.test(value['profileDirectorySha256']) && + (startupAttemptCount === 1 || startupAttemptCount === 2) && + Array.isArray(startupRetryReasons) && + startupRetryReasons.length === startupAttemptCount - 1 && + startupRetryReasons.every( + (reason) => + typeof reason === 'string' && + Object.values(XTREAM_STARTUP_RETRY_REASON).includes( + reason as never + ) + ) ); } diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts index 360fcf8f7..fe2d6815b 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts @@ -136,6 +136,8 @@ export function createXtreamAssemblerFixture( profileDirectorySha256: String(scenarioOrdinal(scenarioId)).repeat( 64 ), + startupAttemptCount: 1, + startupRetryReasons: [], }, rendererCapture, terminal: createTerminal(raw, background), diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts index 31a47bf63..02a73af95 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts @@ -23,6 +23,14 @@ describe('Xtream raw iteration assembler', () => { result.processIdentity.captureGeneration, input.mainCapture.captureGeneration ); + assert.equal( + result.processIdentity.startupAttemptCount, + input.process.startupAttemptCount + ); + assert.deepEqual( + result.processIdentity.startupRetryReasons, + input.process.startupRetryReasons + ); assert.equal( result.databaseWorker.requests.evidence.length, input.mainCapture.requests.length diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts index e2c6ddb53..5cc8a7a2c 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts @@ -33,9 +33,11 @@ import { } from './xtream-scenario-driver'; import { XTREAM_NOT_APPLICABLE, + XTREAM_STARTUP_RETRY_REASON, type XtreamCatalogVerification as XtreamSummaryCatalogVerification, type XtreamIterationProcessIdentity, type XtreamRawIterationResult, + type XtreamStartupRetryReason, } from './xtream-summary-contract'; export interface XtreamIterationProcessEvidence { @@ -43,6 +45,8 @@ export interface XtreamIterationProcessEvidence { readonly freshProcessVerified: true; readonly launchId: string; readonly profileDirectorySha256: string; + readonly startupAttemptCount: 1 | 2; + readonly startupRetryReasons: readonly XtreamStartupRetryReason[]; } export interface XtreamIterationAssemblyInput { @@ -215,7 +219,8 @@ function createProcessIdentity( !Number.isSafeInteger(process.electronPid) || process.electronPid <= 0 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(process.launchId) || - !/^[a-f0-9]{64}$/.test(process.profileDirectorySha256) + !/^[a-f0-9]{64}$/.test(process.profileDirectorySha256) || + !validStartupEvidence(process) ) { invalid(); } @@ -228,6 +233,8 @@ function createProcessIdentity( electronPid: process.electronPid, launchId: process.launchId, profileDirectorySha256: process.profileDirectorySha256, + startupAttemptCount: process.startupAttemptCount, + startupRetryReasons: process.startupRetryReasons, rendererTargetId: input.rendererCapture.targetId, browserWindowId: input.mainCapture.capture.rendererWindow.windowIdentity @@ -245,9 +252,26 @@ function createProcessIdentity( .digest('hex'), launchId: process.launchId, profileDirectorySha256: process.profileDirectorySha256, + startupAttemptCount: process.startupAttemptCount, + startupRetryReasons: [...process.startupRetryReasons], }; } +function validStartupEvidence( + process: XtreamIterationProcessEvidence +): boolean { + return ( + (process.startupAttemptCount === 1 || + process.startupAttemptCount === 2) && + Array.isArray(process.startupRetryReasons) && + process.startupRetryReasons.length === + process.startupAttemptCount - 1 && + process.startupRetryReasons.every((reason) => + Object.values(XTREAM_STARTUP_RETRY_REASON).includes(reason) + ) + ); +} + function requireEpoch(value: unknown): number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) invalid(); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts index 759cc3b06..37b914919 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts @@ -118,6 +118,18 @@ describe('Xtream raw iteration validity', () => { }), /invalid Xtream iteration/ ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + processIdentity: { + ...raw.processIdentity, + startupAttemptCount: 2, + startupRetryReasons: [], + }, + }), + /invalid Xtream iteration/ + ); const { processIdentity: _removed, ...missingIdentity } = raw; void _removed; assert.throws( diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts index 186ee5f7e..7541c2f28 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts @@ -72,10 +72,14 @@ describe('Xtream process evidence', () => { electronPid: 41_001, iterationDirectory, launchId: 'launch-00000000-0000-4000-8000-000000000001', + startupAttemptCount: 1, + startupRetryReasons: [], }); assert.equal(evidence.freshProcessVerified, true); assert.match(evidence.profileDirectorySha256, /^[a-f0-9]{64}$/u); + assert.equal(evidence.startupAttemptCount, 1); + assert.deepEqual(evidence.startupRetryReasons, []); }); it('uses validated diagnostic directory evidence verbatim', async () => { @@ -89,9 +93,33 @@ describe('Xtream process evidence', () => { electronPid: 41_001, iterationDirectory, launchId: 'launch-00000000-0000-4000-8000-000000000001', + startupAttemptCount: 2, + startupRetryReasons: ['sqlite-database-locked'], }); assert.equal(evidence.profileDirectorySha256, directorySha256); + assert.equal(evidence.startupAttemptCount, 2); + assert.deepEqual(evidence.startupRetryReasons, [ + 'sqlite-database-locked', + ]); + }); + + it('rejects inconsistent startup attempt evidence', async () => { + const root = await temporaryDirectory(); + const iterationDirectory = join(root, 'run-01'); + await mkdir(iterationDirectory); + + await assert.rejects( + createXtreamIterationProcessEvidence({ + diagnosticDirectorySha256: null, + electronPid: 41_001, + iterationDirectory, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + startupAttemptCount: 2, + startupRetryReasons: [], + }), + /xtream-electron-process-identity-invalid/ + ); }); }); diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts index 3b25a80ec..bbc8480c2 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts @@ -4,6 +4,10 @@ import { lstat, realpath } from 'node:fs/promises'; import type { ElectronApplication } from '@playwright/test'; import type { XtreamIterationProcessEvidence } from './xtream-iteration-assembler'; +import { + XTREAM_STARTUP_RETRY_REASON, + type XtreamStartupRetryReason, +} from './xtream-summary-contract'; export interface XtreamLaunchIdentity { readonly electronPid: number; @@ -13,6 +17,8 @@ export interface XtreamLaunchIdentity { export interface XtreamIterationProcessEvidenceOptions extends XtreamLaunchIdentity { readonly diagnosticDirectorySha256: string | null; readonly iterationDirectory: string; + readonly startupAttemptCount: number; + readonly startupRetryReasons: readonly XtreamStartupRetryReason[]; } export async function captureXtreamLaunchIdentity( @@ -58,7 +64,11 @@ export async function createXtreamIterationProcessEvidence( if ( !Number.isSafeInteger(options.electronPid) || options.electronPid <= 0 || - !/^launch-[a-f0-9-]{36}$/u.test(options.launchId) + !/^launch-[a-f0-9-]{36}$/u.test(options.launchId) || + !validStartupEvidence( + options.startupAttemptCount, + options.startupRetryReasons + ) ) { invalidProcess(); } @@ -78,6 +88,10 @@ export async function createXtreamIterationProcessEvidence( freshProcessVerified: true, launchId: options.launchId, profileDirectorySha256, + startupAttemptCount: options.startupAttemptCount as 1 | 2, + startupRetryReasons: Object.freeze([ + ...options.startupRetryReasons, + ]), }); } catch (error) { if ( @@ -90,6 +104,20 @@ export async function createXtreamIterationProcessEvidence( } } +function validStartupEvidence( + attemptCount: number, + retryReasons: readonly XtreamStartupRetryReason[] +): boolean { + return ( + (attemptCount === 1 || attemptCount === 2) && + Array.isArray(retryReasons) && + retryReasons.length === attemptCount - 1 && + retryReasons.every((reason) => + Object.values(XTREAM_STARTUP_RETRY_REASON).includes(reason) + ) + ); +} + function sha256(value: string): string { return createHash('sha256').update(value).digest('hex'); } diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts index a402b593e..ea06941c6 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Playwright serializes this self-contained renderer probe; external helpers would be unavailable in page context. */ import type { Page } from '@playwright/test'; import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY, diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 504049d1d..2b0543746 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -130,13 +130,13 @@ describe('Xtream scenario driver source wiring', () => { it('prearms the persistent database worker outside the measured capture', () => { const iteration = source('xtream-benchmark-iteration.ts'); + const startup = source('xtream-benchmark-app-startup.ts'); const support = source('xtream-benchmark-iteration-support.ts'); - const install = iteration.indexOf( + const install = startup.indexOf( 'await installMainCapture(app.electronApp)' ); - const prearm = iteration.indexOf( - 'await prearmXtreamDatabaseWorker(app)' - ); + const prearm = startup.indexOf('await prearmXtreamDatabaseWorker(app)'); + const launch = iteration.indexOf('await launchXtreamBenchmarkApp'); const captureOptions = iteration.indexOf( 'const captureOptions: MainCaptureStartOptions' ); @@ -155,7 +155,22 @@ describe('Xtream scenario driver source wiring', () => { assert.ok(workerPrearm > mainDatabaseReady); assert.ok(install >= 0); assert.ok(prearm > install); - assert.ok(captureOptions > prearm); + assert.ok(launch >= 0); + assert.ok(captureOptions > launch); + assert.match(startup, /maxAttempts:\s*2/); + assert.match(startup, /classifyXtreamStartupFailure/); + assert.match(startup, /disposeXtreamAppResource/); + assert.match(startup, /closeElectronAppAndConfirmExit/); + assert.match( + startup, + /failure instanceof ElectronApplicationDisposalError/ + ); + assert.match(startup, /maxRetries:\s*20/); + assert.match(startup, /retryDelay:\s*250/); + assert.doesNotMatch( + startup, + /removeXtreamDataDirectory\([^)]*\)\.catch/ + ); }); it('subscribes the renderer probe before the cancellation observer is armed', () => { diff --git a/apps/electron-backend-e2e/src/performance/xtream-startup-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-startup-evidence.spec.ts new file mode 100644 index 000000000..d58020fcc --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-startup-evidence.spec.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; + +describe('Xtream startup evidence', () => { + it('rejects an attempt count that does not match its retry reasons', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + process: { + ...input.process, + startupAttemptCount: 2, + startupRetryReasons: [], + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('binds retry evidence into the capture generation identity', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const firstAttempt = assembleXtreamRawIteration(input); + const secondAttempt = assembleXtreamRawIteration({ + ...input, + process: { + ...input.process, + startupAttemptCount: 2, + startupRetryReasons: ['sqlite-database-locked'], + }, + }); + + assert.notEqual( + firstAttempt.processIdentity.generationIdentitySha256, + secondAttempt.processIdentity.generationIdentitySha256 + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-startup-retry.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-startup-retry.spec.ts new file mode 100644 index 000000000..fc508d2c0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-startup-retry.spec.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + classifyXtreamStartupFailure, + runXtreamStartupWithRetry, +} from './xtream-startup-retry'; + +interface FakeResource { + readonly id: number; +} + +describe('Xtream benchmark startup retry', () => { + it('relaunches once with a fresh resource after a locked cold database', async () => { + const created: number[] = []; + const disposed: number[] = []; + const result = await runXtreamStartupWithRetry({ + classifyFailure: classifyXtreamStartupFailure, + create: async (attempt) => { + if (attempt === 2) { + assert.deepEqual(disposed, [1]); + } + created.push(attempt); + return { id: attempt }; + }, + dispose: async ({ id }) => { + disposed.push(id); + }, + maxAttempts: 2, + prepare: async ({ id }) => { + if (id === 1) { + throw new Error( + 'Error invoking remote method: SqliteError: database is locked' + ); + } + return `ready-${id}`; + }, + }); + + assert.deepEqual(created, [1, 2]); + assert.deepEqual(disposed, [1]); + assert.equal(result.attemptCount, 2); + assert.equal(result.prepared, 'ready-2'); + assert.deepEqual(result.resource, { id: 2 }); + assert.deepEqual(result.retryReasons, ['sqlite-database-locked']); + }); + + it('does not retry an unrelated startup failure', async () => { + const failure = new Error('renderer preload contract invalid'); + const created: number[] = []; + const disposed: number[] = []; + + await assert.rejects( + runXtreamStartupWithRetry({ + classifyFailure: classifyXtreamStartupFailure, + create: async (attempt) => { + created.push(attempt); + return { id: attempt }; + }, + dispose: async ({ id }) => { + disposed.push(id); + }, + maxAttempts: 2, + prepare: async () => { + throw failure; + }, + }), + (error: unknown) => error === failure + ); + assert.deepEqual(created, [1]); + assert.deepEqual(disposed, [1]); + }); + + it('does not create a second app when failed-attempt teardown is unproven', async () => { + const created: number[] = []; + const teardownFailure = new Error('electron-process-exit-unconfirmed'); + + await assert.rejects( + runXtreamStartupWithRetry({ + classifyFailure: classifyXtreamStartupFailure, + create: async (attempt) => { + created.push(attempt); + return { id: attempt }; + }, + dispose: async () => { + throw teardownFailure; + }, + maxAttempts: 2, + prepare: async () => { + throw new Error('SqliteError: database is locked'); + }, + }), + (error: unknown) => + error instanceof AggregateError && + error.errors.includes(teardownFailure) + ); + assert.deepEqual(created, [1]); + }); + + it('bounds repeated readiness failures and disposes every failed app', async () => { + const created: number[] = []; + const disposed: number[] = []; + + await assert.rejects( + runXtreamStartupWithRetry({ + classifyFailure: classifyXtreamStartupFailure, + create: async (attempt) => { + created.push(attempt); + return { id: attempt }; + }, + dispose: async ({ id }) => { + disposed.push(id); + }, + maxAttempts: 2, + prepare: async ({ id }) => { + throw new Error(`no such table on attempt ${id}`); + }, + }), + /no such table on attempt 2/ + ); + assert.deepEqual(created, [1, 2]); + assert.deepEqual(disposed, [1, 2]); + }); + + it('classifies only the known cold-start SQLite readiness failures', () => { + assert.equal( + classifyXtreamStartupFailure( + new Error('SqliteError: database is locked') + ), + 'sqlite-database-locked' + ); + assert.equal( + classifyXtreamStartupFailure( + new Error('SqliteError: no such table: playlists') + ), + 'sqlite-schema-not-ready' + ); + assert.equal( + classifyXtreamStartupFailure( + new Error('database disk image malformed') + ), + null + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-startup-retry.ts b/apps/electron-backend-e2e/src/performance/xtream-startup-retry.ts new file mode 100644 index 000000000..4c1ece1cc --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-startup-retry.ts @@ -0,0 +1,92 @@ +import { + XTREAM_STARTUP_RETRY_REASON, + type XtreamStartupRetryReason, +} from './xtream-summary-contract'; + +export interface XtreamStartupRetryOptions { + readonly classifyFailure: ( + failure: unknown + ) => XtreamStartupRetryReason | null; + readonly create: (attempt: number) => Promise; + readonly dispose: (resource: Resource) => Promise; + readonly maxAttempts: number; + readonly prepare: ( + resource: Resource, + attempt: number + ) => Promise; +} + +export interface XtreamStartupRetryResult { + readonly attemptCount: number; + readonly prepared: Prepared; + readonly resource: Resource; + readonly retryReasons: readonly XtreamStartupRetryReason[]; +} + +function readFailureMessage(failure: unknown): string { + return failure instanceof Error ? failure.message : String(failure); +} + +export function classifyXtreamStartupFailure( + failure: unknown +): XtreamStartupRetryReason | null { + const message = readFailureMessage(failure); + if (/database is locked/i.test(message)) { + return XTREAM_STARTUP_RETRY_REASON.SQLITE_DATABASE_LOCKED; + } + if (/no such table/i.test(message)) { + return XTREAM_STARTUP_RETRY_REASON.SQLITE_SCHEMA_NOT_READY; + } + return null; +} + +async function disposeFailedStartup( + dispose: (resource: Resource) => Promise, + failure: unknown, + resource: Resource +): Promise { + try { + await dispose(resource); + } catch (disposeFailure) { + throw new AggregateError( + [failure, disposeFailure], + 'Xtream startup and failed-attempt disposal both failed', + { cause: failure } + ); + } +} + +export async function runXtreamStartupWithRetry( + options: XtreamStartupRetryOptions +): Promise> { + if ( + !Number.isInteger(options.maxAttempts) || + options.maxAttempts < 1 || + options.maxAttempts > 2 + ) { + throw new Error('xtream-startup-attempt-limit-invalid'); + } + + const retryReasons: XtreamStartupRetryReason[] = []; + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + const resource = await options.create(attempt); + try { + const prepared = await options.prepare(resource, attempt); + return Object.freeze({ + attemptCount: attempt, + prepared, + resource, + retryReasons: Object.freeze([...retryReasons]), + }); + } catch (failure) { + const retryReason = options.classifyFailure(failure); + await disposeFailedStartup(options.dispose, failure, resource); + if (!retryReason || attempt === options.maxAttempts) { + throw failure; + } + retryReasons.push(retryReason); + } + } + + throw new Error('xtream-startup-attempts-exhausted'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts index bcaf428ca..7ff73b6cc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts @@ -69,6 +69,14 @@ export const XTREAM_WORKER_WHOLE_METRIC_SCOPE = { OPERATION_WINDOW: 'whole-worker-operation-window', } as const; +export const XTREAM_STARTUP_RETRY_REASON = { + SQLITE_DATABASE_LOCKED: 'sqlite-database-locked', + SQLITE_SCHEMA_NOT_READY: 'sqlite-schema-not-ready', +} as const; + +export type XtreamStartupRetryReason = + (typeof XTREAM_STARTUP_RETRY_REASON)[keyof typeof XTREAM_STARTUP_RETRY_REASON]; + export const XTREAM_STRUCTURED_CLONE_ATTRIBUTION = { SELECTED_IPC_PROXY: 'selected-ipc-proxy-includes-queueing-and-scheduling', } as const; @@ -215,6 +223,8 @@ export interface XtreamIterationProcessIdentity { readonly generationIdentitySha256: string; readonly launchId: string; readonly profileDirectorySha256: string; + readonly startupAttemptCount: 1 | 2; + readonly startupRetryReasons: readonly XtreamStartupRetryReason[]; } export interface XtreamRawIterationResult extends XtreamIterationDefinition { diff --git a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index cf53230db..043976e1b 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -25,6 +25,7 @@ export type XtreamLiveStream = { export type XtreamVodStream = { added?: string; category_id?: string; + container_extension?: string; name?: string; stream_id?: number | string; }; diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index 43c07c7b5..b1565689c 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -8,6 +8,7 @@ import { resetMockServers, test, waitForXtreamWorkspaceReady, + xtreamMockServer, } from './electron-test-fixtures'; import { fetchXtreamVodFixture, @@ -21,7 +22,7 @@ const emptyMetadataCredentials = { }; test.describe('Xtream VOD Details', () => { - test('shows a curated fallback when the portal returns empty VOD metadata', async ({ + test('keeps a sparse VOD playable inside the curated fallback', async ({ dataDir, request, }) => { @@ -30,7 +31,29 @@ test.describe('Xtream VOD Details', () => { request, emptyMetadataCredentials ); - const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle); + const [movieTitle] = pickDistinctTitles( + vodFixture.items, + getXtreamTitle + ); + const movieItem = vodFixture.items.find( + (item) => getXtreamTitle(item) === movieTitle + ); + const streamId = Number(movieItem?.stream_id); + const containerExtension = movieItem?.container_extension?.trim(); + if ( + !Number.isInteger(streamId) || + streamId <= 0 || + !containerExtension + ) { + throw new Error( + 'Xtream VOD fixture returned an invalid playback source.' + ); + } + const expectedMovieUrl = + `${xtreamMockServer}/movie/` + + `${emptyMetadataCredentials.username}/` + + `${emptyMetadataCredentials.password}/` + + `${streamId}.${containerExtension}`; const app = await launchElectronApp(dataDir); try { @@ -43,17 +66,22 @@ test.describe('Xtream VOD Details', () => { await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); - await clickCategoryByNameExact(app.mainWindow, vodFixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); await clickGridListCardByTitle(app.mainWindow, movieTitle); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ ); - await expect(app.mainWindow.locator('app-content-hero')).toContainText( - movieTitle - ); await expect( - app.mainWindow.locator('[data-testid="xtream-vod-fallback-status"]') + app.mainWindow.locator('app-content-hero') + ).toContainText(movieTitle); + await expect( + app.mainWindow.locator( + '[data-testid="xtream-vod-fallback-status"]' + ) ).toContainText('Portal metadata unavailable'); await expect( app.mainWindow.locator('[data-testid="xtream-vod-fallback"]') @@ -61,13 +89,40 @@ test.describe('Xtream VOD Details', () => { 'Extended metadata was not provided by this portal.' ); - await expect(app.mainWindow.locator('button.play-btn')).toHaveCount(0); - await expect(app.mainWindow.locator('button.favorite-btn')).toHaveCount( - 0 - ); - await expect(app.mainWindow.locator('button.download-btn')).toHaveCount( - 0 + const playButton = app.mainWindow + .locator('button.play-btn') + .first(); + await expect(playButton).toBeVisible(); + await expect( + app.mainWindow.locator('button.favorite-btn').first() + ).toBeVisible(); + await expect( + app.mainWindow.locator('button.download-btn').first() + ).toBeVisible(); + + const movieResponsePromise = app.mainWindow.waitForResponse( + (response) => + response.url() === expectedMovieUrl && + response.request().method() === 'GET', + { timeout: 20_000 } ); + await playButton.click(); + + const movieResponse = await movieResponsePromise; + expect(movieResponse.status()).toBe(302); + await expect( + app.mainWindow.locator('app-portal-detail-shell') + ).toHaveClass(/shell-host--watch/); + await expect( + app.mainWindow + .locator('app-portal-inline-player app-web-player-view') + .first() + ).toBeVisible({ timeout: 20_000 }); + await expect( + app.mainWindow.locator( + 'app-portal-inline-player .player-shell__title' + ) + ).toContainText(movieTitle); } finally { await closeElectronApp(app); } diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 1ed1dc64f..9cd127449 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -229,6 +229,21 @@ validity records the exact expected and valid request counts; nullable metrics remain in raw results but cannot be silently omitted from comparison distributions. +The formal Xtream benchmark launches every iteration with a fresh temporary +profile. On that cold profile, the renderer can request the persistent database +worker while the main process is still initializing the shared SQLite schema. +The harness treats only `database is locked` and `no such table` during its +pre-measurement readiness checks as this startup race. It closes the failed app, +confirms that its Electron process exited, removes that profile, and permits one +relaunch with another fresh profile. Unrelated failures are never retried, +unconfirmed teardown or profile cleanup is fatal, a second readiness failure is +fatal, and the measured import, refresh, delete, cancel, or background-UI +operation still has exactly one attempt. Each successful iteration persists +`startup-evidence.json` for immediate diagnostics and also binds the attempt +count and fixed retry reason into the receipted `result.json` process identity. +The validated identity is copied into `summary.json`, so comparison output +cannot hide startup instability. + The main-process benchmark samples the database worker's V8 `used_heap_size` and `external_memory` independently. Raw output includes a valid-sample count for each metric. A peak is numeric only after at least one diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 2eb78db40..121be4848 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -34,9 +34,12 @@ The CI workflow (`.github/workflows/ci.yml`) lints affected projects on PRs (`nx affected`) and every project on master pushes. This enforces `@nx/enforce-module-boundaries` (scope/domain/type tag constraints), the legacy bare-alias ban, and the `max-lines` file-size rule -(hard maximum 400 lines per TypeScript file). Files that predate the -`max-lines` rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after -splitting a baselined file below the limit, regenerate the list with +(hard maximum 400 lines for production TypeScript, 1200 for tests; blank lines +and comments are not counted). The limits live in +`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the +generator import. Files that predate the `max-lines` rule are baselined in +`tools/eslint/max-lines-baseline.mjs`; after splitting a baselined file below +the limit, regenerate the list with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 1f90dbc2b..74f57c1e4 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -91,6 +91,48 @@ If stored Xtream playback credentials contain an invalid server URL or blank username/password, stream URL construction returns an empty URL instead of throwing during playback. +VOD playback uses the standard +`/movie/{username}/{password}/{streamId}.{containerExtension}` URL after +resolving the stream id and container extension as one source. Fields from +`movie_data` take priority, with the top-level catalog fields used as a +fallback when extended metadata is absent or incomplete. A source requires a +positive integer id and a non-empty container extension; otherwise URL +construction returns an empty string. + +The Electron catalog cache does not persist the container extension. If the +merged detail response and cached row still cannot resolve a source, the detail +loader requests the exact VOD from its provider category and merges that raw +catalog row in memory. An Electron route category uses the SQLite row id, so +the loader maps it through the complete persisted category set, including +hidden categories, before sending the provider `category_id`. Cross-portal +Similar links already carry that provider id, so recovery accepts either the +SQLite `id` or `xtream_id` representation while preserving local-id lookup +precedence. If the numeric representations collide, candidate provider ids are +deduplicated and tried in that order until the exact VOD is found. PWA falls +back to its API-backed visible categories, and an unresolved database id is +never sent as though it were a provider id. This recovery request is skipped +when the detail response or owner-valid cached catalog fields are already +sufficient. Recovery is best-effort and never gates the detail page: the initial +sparse selection is published and the loading shell ends before the category +lookup completes, then a successful result upgrades that same fallback +reactively with playback actions. Detail, recovered, and cached +playback fields remain separate candidates: the first complete pair wins, so +two incomplete rows can never synthesize a source. A failed lookup leaves the +already-rendered item safely unplayable. Detail requests are generation- and +playlist-guarded, and detail teardown invalidates the active generation, so a +late response cannot replace a newer selection or repopulate a closed detail. +In-memory VOD category and stream arrays record the playlist that populated +them. Cross-portal Favorites/Recent details ignore arrays owned by another +playlist, so colliding Xtream ids cannot suppress recovery or contribute a +foreign playback extension, title, poster, category, or recommendation. + +Metadata availability and VOD playability are independent. An empty or sparse +`get_vod_info` response keeps the curated fallback detail page, but that page +offers the same Play/Resume, Favorite, and Download actions when the source +resolves. An unresolved source exposes no actions. Playback and download +descriptors also fall back to the catalog title and poster after `info` and +`movie_data`. + ## Catch-Up Playback URLs Xtream-compatible portals differ on archive playback URL shape. IPTVnator diff --git a/eslint.config.mjs b/eslint.config.mjs index 57bef608a..7b78ea47c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,11 @@ import nx from '@nx/eslint-plugin'; import { maxLinesBaseline } from './tools/eslint/max-lines-baseline.mjs'; +import { + MAX_LINES_OPTIONS, + MAX_LINES_PROD, + MAX_LINES_TEST, + TEST_FILE_GLOBS, +} from './tools/eslint/max-lines-config.mjs'; const legacyBareAliases = [ 'components', @@ -248,13 +254,25 @@ export default [ }, }, { - // CLAUDE.md file-size rule: keep TypeScript files under 300 lines, + // CLAUDE.md file-size rule for production code: target under 300 lines, // hard maximum 400. Files that predate the rule are baselined below. files: ['**/*.ts', '**/*.tsx'], + ignores: TEST_FILE_GLOBS, rules: { 'max-lines': [ 'error', - { max: 400, skipBlankLines: false, skipComments: false }, + { max: MAX_LINES_PROD, ...MAX_LINES_OPTIONS }, + ], + }, + }, + { + // Tests get a much higher ceiling — see tools/eslint/max-lines-config.mjs + // for why a long spec is not the same signal as a long component. + files: TEST_FILE_GLOBS, + rules: { + 'max-lines': [ + 'error', + { max: MAX_LINES_TEST, ...MAX_LINES_OPTIONS }, ], }, }, diff --git a/libs/portal/xtream/data-access/src/lib/services/index.ts b/libs/portal/xtream/data-access/src/lib/services/index.ts index 69f0513b9..9b486e314 100644 --- a/libs/portal/xtream/data-access/src/lib/services/index.ts +++ b/libs/portal/xtream/data-access/src/lib/services/index.ts @@ -3,4 +3,5 @@ export * from './favorite-item.interface'; export * from './favorites.service'; export * from './xtream-api.service'; export * from './xtream-url.service'; +export * from './xtream-vod-playback-source'; export * from './xtream-xmltv-fallback.service'; diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts index e49a07319..b2f0679f4 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -115,6 +115,43 @@ describe('XtreamApiService', () => { ); }); + it('loads one VOD catalog item from its category', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + payload: [ + { + stream_id: 41, + category_id: '7', + container_extension: 'mkv', + }, + { + stream_id: 42, + category_id: '7', + container_extension: 'mp4', + }, + ], + }); + + const item = await service.getVodStream(credentials, 42, 7); + + expect(item).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mp4', + }) + ); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + XTREAM_REQUEST, + expect.objectContaining({ + params: { + action: 'get_vod_streams', + category_id: '7', + password: 'secret', + username: 'demo', + }, + }) + ); + }); + it('falls back to the legacy full-epg action and normalizes the response', async () => { dataService.sendIpcEvent.mockImplementation( async (_type: string, payload: unknown) => { diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index d85aa843b..2d46830b9 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -41,6 +41,10 @@ export interface XtreamRequestOptions { suppressErrorLog?: boolean; } +export interface XtreamVodCatalogRequestOptions extends XtreamRequestOptions { + categoryId?: string | number; +} + const XTREAM_ACCOUNT_ACTIONS = [ XtreamCodeActions.GetAccountInfo, null, @@ -191,7 +195,7 @@ export class XtreamApiService { */ async getVodStreams( credentials: XtreamCredentials, - options?: XtreamRequestOptions + options?: XtreamVodCatalogRequestOptions ): Promise { const response = await this.sendRequest( credentials.serverUrl, @@ -199,6 +203,9 @@ export class XtreamApiService { action: XtreamCodeActions.GetVodStreams, username: credentials.username, password: credentials.password, + ...(options?.categoryId !== undefined + ? { category_id: options.categoryId } + : {}), }, options ); @@ -206,6 +213,24 @@ export class XtreamApiService { return Array.isArray(response) ? response : []; } + async getVodStream( + credentials: XtreamCredentials, + vodId: string | number, + categoryId: string | number, + options?: XtreamRequestOptions + ): Promise { + const streams = await this.getVodStreams(credentials, { + ...options, + categoryId, + }); + + return ( + streams.find( + (stream) => Number(stream.stream_id) === Number(vodId) + ) ?? null + ); + } + /** * Get series items */ diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index c1f837f94..a6599ab54 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -120,6 +120,21 @@ describe('XtreamUrlService', () => { expect(url).toBe('http://demo.example/live/demo/secret/101.ts'); }); + it('constructs a VOD URL from catalog fields when metadata is empty', () => { + const vodItem = { + info: [], + stream_id: 101, + container_extension: 'mp4', + } as XtreamVodDetails & { + stream_id: number; + container_extension: string; + }; + + expect(service.constructVodUrl(credentials, vodItem)).toBe( + 'http://demo.example/movie/demo/secret/101.mp4' + ); + }); + it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => { const invalidCredentials: XtreamCredentials = { ...credentials, diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 8aa9c49b3..b2c8650b6 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -7,6 +7,7 @@ import { } from '@iptvnator/shared/interfaces'; import { DatabaseService, SettingsStore } from '@iptvnator/services'; import { XtreamCredentials } from './xtream-api.service'; +import { resolveXtreamVodPlaybackSource } from './xtream-vod-playback-source'; /** * Extended playlist with optional HTTP headers @@ -28,10 +29,6 @@ export interface LiveStreamItem { [key: string]: unknown; } -type XtreamVodStreamLike = XtreamVodDetails & { - readonly stream_id?: number; -}; - const XTREAM_CATCHUP_SCHEME = { LEGACY: 'legacy', REST: 'rest', @@ -124,10 +121,8 @@ export class XtreamUrlService { credentials: XtreamCredentials, vodItem: XtreamVodDetails ): string { - const vod = vodItem as XtreamVodStreamLike; - const streamId = vod.movie_data?.stream_id ?? vod.stream_id; - const extension = vodItem.movie_data?.container_extension; - if (!streamId || !extension) { + const source = resolveXtreamVodPlaybackSource(vodItem); + if (!source) { return ''; } const normalizedCredentials = this.normalizeCredentials(credentials); @@ -135,7 +130,7 @@ export class XtreamUrlService { return ''; } - return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`; + return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${source.streamId}.${source.containerExtension}`; } /** diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts new file mode 100644 index 000000000..a2e55ba94 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.spec.ts @@ -0,0 +1,72 @@ +import { XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { + resolveXtreamVodPlaybackSource, + XtreamVodPlaybackItem, +} from './xtream-vod-playback-source'; + +function createMovieDataDetails( + overrides: Partial> = {} +): XtreamVodDetails { + return { + movie_data: { + added: '', + category_id: '', + container_extension: 'mp4', + custom_sid: null, + direct_source: '', + name: 'Movie', + stream_id: 101, + ...overrides, + }, + }; +} + +describe('resolveXtreamVodPlaybackSource', () => { + it('resolves a source from movie_data', () => { + expect( + resolveXtreamVodPlaybackSource(createMovieDataDetails()) + ).toEqual({ + streamId: 101, + containerExtension: 'mp4', + }); + }); + + it('falls back to catalog fields', () => { + const item: XtreamVodPlaybackItem = { + info: [], + stream_id: '202', + container_extension: ' mkv ', + }; + + expect(resolveXtreamVodPlaybackSource(item)).toEqual({ + streamId: 202, + containerExtension: 'mkv', + }); + }); + + it('prefers movie_data over catalog fields', () => { + const item: XtreamVodPlaybackItem = { + ...createMovieDataDetails(), + stream_id: 202, + container_extension: 'mkv', + }; + + expect(resolveXtreamVodPlaybackSource(item)).toEqual({ + streamId: 101, + containerExtension: 'mp4', + }); + }); + + it.each([ + undefined, + {}, + { stream_id: 0, container_extension: 'mp4' }, + { stream_id: -1, container_extension: 'mp4' }, + { stream_id: 1.5, container_extension: 'mp4' }, + { stream_id: 'not-an-id', container_extension: 'mp4' }, + { stream_id: 101 }, + { stream_id: 101, container_extension: ' ' }, + ] as const)('returns null for an unusable item %#', (item) => { + expect(resolveXtreamVodPlaybackSource(item)).toBeNull(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts new file mode 100644 index 000000000..fb2c5caec --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-vod-playback-source.ts @@ -0,0 +1,51 @@ +import { XtreamVodDetails } from '@iptvnator/shared/interfaces'; + +export type XtreamVodPlaybackItem = XtreamVodDetails & { + readonly stream_id?: number | string | null; + readonly container_extension?: string | null; +}; + +export interface XtreamVodPlaybackSource { + readonly streamId: number; + readonly containerExtension: string; +} + +function resolveCandidate( + streamIdValue: unknown, + containerExtensionValue: unknown +): XtreamVodPlaybackSource | null { + const streamId = + typeof streamIdValue === 'string' && streamIdValue.trim() + ? Number(streamIdValue) + : streamIdValue; + const containerExtension = + typeof containerExtensionValue === 'string' + ? containerExtensionValue.trim() + : ''; + + if ( + typeof streamId !== 'number' || + !Number.isSafeInteger(streamId) || + streamId <= 0 || + !containerExtension + ) { + return null; + } + + return { streamId, containerExtension }; +} + +export function resolveXtreamVodPlaybackSource( + item: XtreamVodPlaybackItem | null | undefined +): XtreamVodPlaybackSource | null { + if (!item) { + return null; + } + + return ( + resolveCandidate( + item.movie_data?.stream_id, + item.movie_data?.container_extension + ) ?? resolveCandidate(item.stream_id, item.container_extension) + ); +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index a7d9a4da6..d210d259e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -486,6 +486,9 @@ describe('withContent import state', () => { pendingCategories.live.resolve([]); pendingCategories.vod.resolve([]); pendingCategories.series.resolve([]); + await waitForCondition( + () => store.vodCategoriesPlaylistId() === PLAYLIST.id + ); pending.live.resolve(liveItems); await waitForCondition( @@ -506,6 +509,7 @@ describe('withContent import state', () => { ); expect(store.vodStreams()).toEqual(vodItems); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); expect(store.contentLoadStateByType()).toEqual({ live: 'ready', vod: 'ready', @@ -525,6 +529,21 @@ describe('withContent import state', () => { expect(store.isContentInitialized()).toBe(true); }); + it('records the VOD category owner when categories are reloaded', async () => { + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: 'live' | 'vod' | 'series' + ) => Promise.resolve([{ category_id: type }]) + ); + + await store.reloadCategories(); + + expect(store.vodCategories()).toEqual([{ category_id: 'vod' }]); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + }); + it('loads categories before starting content import', async () => { const pendingCategories = { live: createDeferred(), @@ -653,12 +672,25 @@ describe('withContent import state', () => { expect(dataSource.getContent).not.toHaveBeenCalled(); expect(store.vodCategories()).toHaveLength(1); expect(store.vodStreams()).toHaveLength(1); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); expect(store.contentLoadStateByType().vod).toBe('ready'); expect(store.isCachedContentScopeReady('vod')).toBe(true); expect(store.isContentInitialized()).toBe(true); expect(store.contentInitBlockReason()).toBeNull(); }); + it('clears VOD catalog owners with the content state', async () => { + await store.hydrateCachedContent('vod'); + expect(store.vodCategoriesPlaylistId()).toBe(PLAYLIST.id); + expect(store.vodStreamsPlaylistId()).toBe(PLAYLIST.id); + + store.resetContent(); + + expect(store.vodCategoriesPlaylistId()).toBeNull(); + expect(store.vodStreamsPlaylistId()).toBeNull(); + }); + it('exposes loading state while cached section content is hydrating', async () => { const cachedCategories = createDeferred(); const cachedContent = createDeferred(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 4ba66c065..15ac0f490 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -86,9 +86,11 @@ const clearCancelledPlaylistInitializationLock = (playlistId: string): void => { export interface ContentState { liveCategories: (XtreamCategory | XtreamCategoryFromDb)[]; vodCategories: (XtreamCategory | XtreamCategoryFromDb)[]; + vodCategoriesPlaylistId: string | null; serialCategories: (XtreamCategory | XtreamCategoryFromDb)[]; liveStreams: XtreamLiveStream[]; vodStreams: XtreamVodStream[]; + vodStreamsPlaylistId: string | null; serialStreams: XtreamSerieItem[]; isLoadingCategories: boolean; isLoadingContent: boolean; @@ -119,9 +121,11 @@ const initialContentLoadStateByType: XtreamContentLoadStateByType = { const initialContentState: ContentState = { liveCategories: [], vodCategories: [], + vodCategoriesPlaylistId: null, serialCategories: [], liveStreams: [], vodStreams: [], + vodStreamsPlaylistId: null, serialStreams: [], isLoadingCategories: false, isLoadingContent: false, @@ -541,10 +545,12 @@ export function withContent() { break; case 'vod': updates.vodCategories = entry.categories; + updates.vodCategoriesPlaylistId = playlistId; updates.vodStreams = asCachedContent( entry.content ); + updates.vodStreamsPlaylistId = playlistId; break; case 'series': updates.serialCategories = entry.categories; @@ -984,6 +990,7 @@ export function withContent() { patchState(store, { liveCategories: live, vodCategories: vod, + vodCategoriesPlaylistId: ctx.playlistId, serialCategories: series, isLoadingCategories: false, }), @@ -1113,6 +1120,7 @@ export function withContent() { () => patchState(store, { vodStreams: vod, + vodStreamsPlaylistId: ctx.playlistId, }), () => ({ items: vod.length }) ); @@ -1308,6 +1316,7 @@ export function withContent() { patchState(store, { liveCategories: live, vodCategories: vod, + vodCategoriesPlaylistId: ctx.playlistId, serialCategories: series, }); } catch (error) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts new file mode 100644 index 000000000..e493c5576 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-category-recovery.spec.ts @@ -0,0 +1,97 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { recoverXtreamVodCatalogItem } from './xtream-details-request'; + +describe('Xtream VOD category recovery', () => { + const credentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + it('tries a local category mapping before a colliding provider id', async () => { + const recoveredItem = { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream; + const getVodStream = jest.fn( + async ( + _credentials: typeof credentials, + _vodId: string | number, + categoryId: string | number + ) => (Number(categoryId) === 7 ? recoveredItem : null) + ); + const getAllCategories = jest.fn(); + const getCategories = jest.fn(); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [ + { id: 7, xtream_id: 701 }, + { id: 8, xtream_id: 7 }, + ], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBe(recoveredItem); + expect(getVodStream.mock.calls).toEqual([ + [credentials, 42, 701], + [credentials, 42, 7], + ]); + expect(getAllCategories).not.toHaveBeenCalled(); + expect(getCategories).not.toHaveBeenCalled(); + }); + + it('deduplicates one provider category across all category sources', async () => { + const getVodStream = jest.fn().mockResolvedValue(null); + const getAllCategories = jest + .fn() + .mockResolvedValue([{ id: 7, xtream_id: 7 }]); + const getCategories = jest + .fn() + .mockResolvedValue([{ category_id: '7' }]); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 7 }], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBeNull(); + expect(getVodStream).toHaveBeenCalledTimes(1); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 7); + }); + + it('does not request a VOD for an unresolved route category', async () => { + const getVodStream = jest.fn(); + const getAllCategories = jest + .fn() + .mockResolvedValue([{ id: 8, xtream_id: 701 }]); + const getCategories = jest + .fn() + .mockResolvedValue([{ category_id: '702' }]); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 7, + vodId: 42, + }) + ).resolves.toBeNull(); + expect(getVodStream).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts new file mode 100644 index 000000000..eb06719ee --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.spec.ts @@ -0,0 +1,293 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + createXtreamDetailsRequestGuard, + recoverXtreamVodCatalogItem, + resolveXtreamVodDetailsSelection, +} from './xtream-details-request'; + +describe('Xtream details request coordination', () => { + const credentials = { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + it('invalidates older detail requests and requests from another playlist', () => { + let currentPlaylistId = 'playlist-a'; + const guard = createXtreamDetailsRequestGuard(() => currentPlaylistId); + const firstRequestIsCurrent = guard.begin('playlist-a'); + const secondRequestIsCurrent = guard.begin('playlist-a'); + + expect(firstRequestIsCurrent()).toBe(false); + expect(secondRequestIsCurrent()).toBe(true); + + currentPlaylistId = 'playlist-b'; + expect(secondRequestIsCurrent()).toBe(false); + + const thirdRequestIsCurrent = guard.begin('playlist-b'); + guard.invalidate(); + expect(thirdRequestIsCurrent()).toBe(false); + }); + + it.each([7, 701])( + 'recovers from Electron route category id %s', + async (routeCategoryId) => { + const getAllCategories = jest.fn().mockResolvedValue([ + { + id: 7, + xtream_id: 701, + }, + ]); + const getCategories = jest.fn().mockResolvedValue([]); + const getVodStream = jest.fn().mockResolvedValue({ + stream_id: 42, + container_extension: 'mp4', + }); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId, + vodId: 42, + }) + ).resolves.toEqual({ + stream_id: 42, + container_extension: 'mp4', + }); + expect(getAllCategories).toHaveBeenCalledWith( + 'playlist-a', + 'movies' + ); + expect(getCategories).not.toHaveBeenCalled(); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701); + } + ); + + it('falls back to the visible API categories when PWA has no persisted categories', async () => { + const getAllCategories = jest.fn().mockResolvedValue([]); + const getCategories = jest + .fn() + .mockResolvedValue([{ category_id: 701 }]); + const getVodStream = jest.fn().mockResolvedValue({ + stream_id: 42, + container_extension: 'mp4', + }); + + await recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [], + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-a', + routeCategoryId: 701, + vodId: 42, + }); + + expect(getCategories).toHaveBeenCalledWith( + 'playlist-a', + credentials, + 'vod' + ); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701); + }); + + it('drops a catalog recovery result after a newer request starts', async () => { + let isCurrent = true; + const getVodStream = jest.fn().mockImplementation(async () => { + isCurrent = false; + return { + stream_id: 42, + container_extension: 'mp4', + }; + }); + + await expect( + recoverXtreamVodCatalogItem({ + apiService: { getVodStream }, + currentCategories: [{ category_id: 701 }], + credentials, + dataSource: { + getAllCategories: jest.fn(), + getCategories: jest.fn(), + }, + isCurrent: () => isCurrent, + playlistId: 'playlist-a', + routeCategoryId: 701, + vodId: 42, + }) + ).resolves.toBeNull(); + }); + + it('ignores stale catalog owners and resolves against the active playlist', async () => { + const getAllCategories = jest + .fn() + .mockResolvedValue([{ id: 7, xtream_id: 701 }]); + const getCategories = jest.fn().mockResolvedValue([]); + const getVodStream = jest.fn().mockResolvedValue({ + stream_id: 42, + container_extension: 'mkv', + }); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 999 }], + currentCategoriesPlaylistId: 'playlist-a', + currentStreams: [ + { + stream_id: 42, + container_extension: 'mp4', + xtream_id: 42, + } as XtreamVodStream, + ], + currentStreamsPlaylistId: 'playlist-a', + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolveXtreamVodPlaybackSource(resolution.selection)).toBeNull(); + expect(resolution.recovery).not.toBeNull(); + if (!resolution.recovery) { + throw new Error('Expected catalog recovery to start'); + } + const result = await resolution.recovery; + + expect(resolveXtreamVodPlaybackSource(result.selection)).toEqual({ + streamId: 42, + containerExtension: 'mkv', + }); + expect(getAllCategories).toHaveBeenCalledWith('playlist-b', 'movies'); + expect(getCategories).not.toHaveBeenCalled(); + expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701); + }); + + it('uses a catalog pair owned by the active playlist without recovery', () => { + const getAllCategories = jest.fn(); + const getCategories = jest.fn(); + const getVodStream = jest.fn(); + const result = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [ + { + stream_id: 42, + container_extension: 'mp4', + xtream_id: 42, + } as XtreamVodStream, + ], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { getAllCategories, getCategories }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolveXtreamVodPlaybackSource(result.selection)).toEqual({ + streamId: 42, + containerExtension: 'mp4', + }); + expect(result.recovery).toBeNull(); + expect(getAllCategories).not.toHaveBeenCalled(); + expect(getCategories).not.toHaveBeenCalled(); + expect(getVodStream).not.toHaveBeenCalled(); + }); + + it('returns sparse details before catalog recovery completes', async () => { + let resolveCatalogItem!: (item: XtreamVodStream | null) => void; + const getVodStream = jest.fn( + () => + new Promise((resolve) => { + resolveCatalogItem = resolve; + }) + ); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { getVodStream }, + currentCategories: [{ id: 7, xtream_id: 701 }], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { + getAllCategories: jest.fn(), + getCategories: jest.fn(), + }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolution).not.toBeInstanceOf(Promise); + expect(resolveXtreamVodPlaybackSource(resolution.selection)).toBeNull(); + expect(resolution.recovery).not.toBeNull(); + if (!resolution.recovery) { + return; + } + + let recoverySettled = false; + void resolution.recovery.finally(() => { + recoverySettled = true; + }); + await Promise.resolve(); + expect(recoverySettled).toBe(false); + + resolveCatalogItem({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + + await expect(resolution.recovery).resolves.toEqual( + expect.objectContaining({ + selection: expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }), + }) + ); + }); + + it('contains catalog recovery errors without rejecting the fallback', async () => { + const recoveryError = new Error('Catalog unavailable'); + const resolution = resolveXtreamVodDetailsSelection({ + apiService: { + getVodStream: jest.fn().mockRejectedValue(recoveryError), + }, + currentCategories: [{ id: 7, xtream_id: 701 }], + currentCategoriesPlaylistId: 'playlist-b', + currentStreams: [], + currentStreamsPlaylistId: 'playlist-b', + credentials, + dataSource: { + getAllCategories: jest.fn(), + getCategories: jest.fn(), + }, + isCurrent: () => true, + playlistId: 'playlist-b', + routeCategoryId: 7, + vodDetails: { info: [] }, + vodId: 42, + }); + + expect(resolution.recovery).not.toBeNull(); + await expect(resolution.recovery).resolves.toEqual({ + recoveredCatalogItem: null, + recoveryError, + selection: resolution.selection, + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts new file mode 100644 index 000000000..7d85a1b48 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-details-request.ts @@ -0,0 +1,238 @@ +import { + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { XtreamCredentials } from '../services/xtream-api.service'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + buildXtreamVodSelection, + findXtreamVodCatalogItem, + resolveXtreamVodCatalogCategoryIds, + XtreamVodCatalogCategory, + XtreamVodSelection, +} from './xtream-vod-selection'; + +type PlaylistId = string | null | undefined; + +export function createXtreamDetailsRequestGuard( + getCurrentPlaylistId: () => PlaylistId +) { + let generation = 0; + + return { + begin(expectedPlaylistId: string): () => boolean { + const requestGeneration = ++generation; + return () => + requestGeneration === generation && + getCurrentPlaylistId() === expectedPlaylistId; + }, + invalidate(): void { + generation += 1; + }, + }; +} + +interface RecoverXtreamVodCatalogItemOptions { + readonly apiService: { + getVodStream( + credentials: XtreamCredentials, + vodId: string | number, + categoryId: string | number + ): Promise; + }; + readonly currentCategories: readonly XtreamVodCatalogCategory[]; + readonly credentials: XtreamCredentials; + readonly dataSource: { + getAllCategories( + playlistId: string, + type: 'movies' + ): Promise; + getCategories( + playlistId: string, + credentials: XtreamCredentials, + type: 'vod' + ): Promise; + }; + readonly isCurrent: () => boolean; + readonly playlistId: string; + readonly routeCategoryId: string | number; + readonly vodId: string | number; +} + +interface ResolveXtreamVodDetailsSelectionOptions extends Omit< + RecoverXtreamVodCatalogItemOptions, + 'currentCategories' +> { + readonly currentCategories: readonly XtreamVodCatalogCategory[]; + readonly currentCategoriesPlaylistId: PlaylistId; + readonly currentStreams: readonly XtreamVodStream[]; + readonly currentStreamsPlaylistId: PlaylistId; + readonly vodDetails: XtreamVodDetails; +} + +export interface ResolveXtreamVodDetailsSelectionResult { + readonly recovery: Promise | null; + readonly selection: XtreamVodSelection; +} + +export interface XtreamVodDetailsRecoveryResult { + readonly recoveredCatalogItem: XtreamVodStream | null; + readonly recoveryError?: unknown; + readonly selection: XtreamVodSelection; +} + +export function applyRecoveredXtreamVodCatalogItem( + currentSelection: { + readonly [key: string]: unknown; + readonly stream_id?: string | number; + readonly xtream_id?: number; + } | null, + recoveredCatalogItem: XtreamVodStream | null, + vodId: string | number +): XtreamVodSelection | null { + const currentVodId = + currentSelection?.xtream_id ?? currentSelection?.stream_id; + if ( + !currentSelection || + !recoveredCatalogItem || + Number(currentVodId) !== Number(vodId) + ) { + return null; + } + + return buildXtreamVodSelection( + currentSelection as XtreamVodDetails, + undefined, + vodId, + recoveredCatalogItem + ); +} + +export async function recoverXtreamVodCatalogItem({ + apiService, + currentCategories, + credentials, + dataSource, + isCurrent, + playlistId, + routeCategoryId, + vodId, +}: RecoverXtreamVodCatalogItemOptions): Promise { + if (!isCurrent()) { + return null; + } + + const attemptedCategoryIds = new Set(); + const findInCategories = async ( + categories: readonly XtreamVodCatalogCategory[] + ): Promise => { + const providerCategoryIds = resolveXtreamVodCatalogCategoryIds( + categories, + routeCategoryId + ); + for (const providerCategoryId of providerCategoryIds) { + const normalizedProviderId = Number(providerCategoryId); + if (attemptedCategoryIds.has(normalizedProviderId)) { + continue; + } + attemptedCategoryIds.add(normalizedProviderId); + if (!isCurrent()) { + return null; + } + + const item = await apiService.getVodStream( + credentials, + vodId, + providerCategoryId + ); + if (!isCurrent()) { + return null; + } + if (item) { + return item; + } + } + + return null; + }; + + let item = await findInCategories(currentCategories); + if (item || !isCurrent()) { + return item; + } + + const persistedCategories = await dataSource.getAllCategories( + playlistId, + 'movies' + ); + if (!isCurrent()) { + return null; + } + item = await findInCategories(persistedCategories); + if (item || !isCurrent()) { + return item; + } + + const categories = await dataSource.getCategories( + playlistId, + credentials, + 'vod' + ); + if (!isCurrent()) { + return null; + } + + return findInCategories(categories); +} + +export function resolveXtreamVodDetailsSelection({ + currentCategories, + currentCategoriesPlaylistId, + currentStreams, + currentStreamsPlaylistId, + vodDetails, + ...recoveryOptions +}: ResolveXtreamVodDetailsSelectionOptions): ResolveXtreamVodDetailsSelectionResult { + const catalogItem = + currentStreamsPlaylistId === recoveryOptions.playlistId + ? findXtreamVodCatalogItem(currentStreams, recoveryOptions.vodId) + : undefined; + const selection = buildXtreamVodSelection( + vodDetails, + catalogItem, + recoveryOptions.vodId + ); + + if (resolveXtreamVodPlaybackSource(selection)) { + return { recovery: null, selection }; + } + + const recovery = recoverXtreamVodCatalogItem({ + ...recoveryOptions, + currentCategories: + currentCategoriesPlaylistId === recoveryOptions.playlistId + ? currentCategories + : [], + }) + .then((recoveredCatalogItem) => ({ + recoveredCatalogItem, + selection: recoveredCatalogItem + ? buildXtreamVodSelection( + vodDetails, + catalogItem, + recoveryOptions.vodId, + recoveredCatalogItem + ) + : selection, + })) + .catch((recoveryError: unknown) => ({ + recoveredCatalogItem: null, + recoveryError, + selection, + })); + + return { + recovery, + selection, + }; +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts new file mode 100644 index 000000000..b075af04c --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.spec.ts @@ -0,0 +1,151 @@ +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + buildXtreamVodSelection, + findXtreamVodCatalogItem, + resolveXtreamVodCatalogCategoryId, +} from './xtream-vod-selection'; + +describe('Xtream VOD selection helpers', () => { + it('merges a sparse detail response with its catalog playback fields', () => { + const catalogItem = { + stream_id: 42, + container_extension: 'mp4', + name: 'Catalog title', + stream_icon: 'catalog-poster.jpg', + xtream_id: 42, + } as XtreamVodStream; + + expect(buildXtreamVodSelection({ info: [] }, catalogItem, 42)).toEqual( + expect.objectContaining({ + info: [], + stream_id: 42, + container_extension: 'mp4', + name: 'Catalog title', + stream_icon: 'catalog-poster.jpg', + xtream_id: 42, + }) + ); + }); + + it('keeps a complete detail playback pair ahead of a catalog collision', () => { + const catalogItem = { + stream_id: 42, + container_extension: 'mkv', + xtream_id: 42, + } as XtreamVodStream; + + expect( + buildXtreamVodSelection( + { + info: [], + stream_id: 42, + container_extension: 'mp4', + } as unknown as Parameters[0], + catalogItem, + 42 + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mp4', + }) + ); + }); + + it('falls back to one complete catalog pair instead of mixing fields', () => { + const catalogItem = { + stream_id: 42, + container_extension: 'mkv', + xtream_id: 42, + } as XtreamVodStream; + + expect( + buildXtreamVodSelection( + { + info: [], + stream_id: 99, + } as unknown as Parameters[0], + catalogItem, + 42 + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }) + ); + }); + + it('prefers a complete recovered pair over the cached catalog pair', () => { + expect( + buildXtreamVodSelection( + { info: [] }, + { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream, + 42, + { + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream + ) + ).toEqual( + expect.objectContaining({ + stream_id: 42, + container_extension: 'mkv', + }) + ); + }); + + it('does not synthesize a source from incomplete catalog candidates', () => { + const selection = buildXtreamVodSelection( + { info: [] }, + { stream_id: 42 } as XtreamVodStream, + 42, + { container_extension: 'mkv' } as XtreamVodStream + ); + + expect(resolveXtreamVodPlaybackSource(selection)).toBeNull(); + }); + + it('finds a catalog row by its provider VOD id', () => { + const matchingItem = { + stream_id: 42, + container_extension: 'mp4', + } as XtreamVodStream; + + expect( + findXtreamVodCatalogItem( + [{ stream_id: 7 } as XtreamVodStream, matchingItem], + 42 + ) + ).toBe(matchingItem); + }); + + it('maps an Electron database category id back to the provider id', () => { + expect( + resolveXtreamVodCatalogCategoryId([{ id: 7, xtream_id: 701 }], 7) + ).toBe(701); + expect( + resolveXtreamVodCatalogCategoryId([{ category_id: '702' }], '702') + ).toBe('702'); + }); + + it('accepts a provider category id from cross-portal detail links', () => { + expect( + resolveXtreamVodCatalogCategoryId([{ id: 7, xtream_id: 701 }], 701) + ).toBe(701); + }); + + it('does not treat an optional PWA id as a database category id', () => { + expect( + resolveXtreamVodCatalogCategoryId([{ id: 7, category_id: 701 }], 7) + ).toBeNull(); + }); + + it('does not treat an unresolved database category id as a provider id', () => { + expect(resolveXtreamVodCatalogCategoryId([], 7)).toBeNull(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts new file mode 100644 index 000000000..07c062732 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-vod-selection.ts @@ -0,0 +1,111 @@ +import { + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { + resolveXtreamVodPlaybackSource, + XtreamVodPlaybackItem, +} from '../services/xtream-vod-playback-source'; + +export type XtreamVodSelection = XtreamVodDetails & { + [key: string]: unknown; + container_extension?: string | null; + stream_id: string | number; + xtream_id: number; +}; + +export interface XtreamVodCatalogCategory { + readonly category_id?: string | number; + readonly id?: string | number; + readonly xtream_id?: string | number; +} + +export function buildXtreamVodSelection( + vodDetails: XtreamVodDetails, + catalogItem: XtreamVodStream | undefined, + vodId: string | number, + recoveredCatalogItem?: XtreamVodStream +): XtreamVodSelection { + const detailItem = vodDetails as XtreamVodPlaybackItem; + const playbackSource = + resolveXtreamVodPlaybackSource(detailItem) ?? + resolveXtreamVodPlaybackSource( + recoveredCatalogItem as unknown as XtreamVodPlaybackItem + ) ?? + resolveXtreamVodPlaybackSource( + catalogItem as unknown as XtreamVodPlaybackItem + ); + + return { + ...catalogItem, + ...recoveredCatalogItem, + ...vodDetails, + stream_id: playbackSource?.streamId ?? Number(vodId), + container_extension: playbackSource?.containerExtension ?? null, + xtream_id: + recoveredCatalogItem?.xtream_id ?? + catalogItem?.xtream_id ?? + Number(vodId), + }; +} + +export function findXtreamVodCatalogItem( + items: readonly XtreamVodStream[], + vodId: string | number +): XtreamVodStream | undefined { + return items.find((item) => { + const candidateId = + item.xtream_id ?? + item.stream_id ?? + (item as { id?: string | number }).id; + + return Number(candidateId) === Number(vodId); + }); +} + +export function resolveXtreamVodCatalogCategoryId( + categories: readonly XtreamVodCatalogCategory[], + categoryId: string | number +): string | number | null { + return ( + resolveXtreamVodCatalogCategoryIds(categories, categoryId)[0] ?? null + ); +} + +export function resolveXtreamVodCatalogCategoryIds( + categories: readonly XtreamVodCatalogCategory[], + categoryId: string | number +): (string | number)[] { + const normalizedCategoryId = Number(categoryId); + if (!Number.isFinite(normalizedCategoryId)) { + return []; + } + + const candidates: (string | number)[] = []; + const seen = new Set(); + const addProviderId = (category: XtreamVodCatalogCategory): void => { + const providerId = category.xtream_id ?? category.category_id; + if (providerId === undefined || providerId === null) { + return; + } + const normalizedProviderId = Number(providerId); + if ( + Number.isFinite(normalizedProviderId) && + !seen.has(normalizedProviderId) + ) { + seen.add(normalizedProviderId); + candidates.push(providerId); + } + }; + const matches = (value: string | number | undefined): boolean => + value !== undefined && Number(value) === normalizedCategoryId; + + categories + .filter((item) => item.xtream_id !== undefined && matches(item.id)) + .forEach(addProviderId); + categories + .filter((item) => matches(item.xtream_id) || matches(item.category_id)) + .forEach(addProviderId); + + return candidates; +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts new file mode 100644 index 000000000..7e2b8bba7 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -0,0 +1,243 @@ +import { TestBed } from '@angular/core/testing'; +import { + DatabaseService, + PlaybackPositionRuntimeBridgeService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, + TmdbEnrichmentService, + XtreamPendingRestoreService, +} from '@iptvnator/services'; +import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; +import { XtreamVodStream } from '@iptvnator/shared/interfaces'; +import { + XTREAM_DATA_SOURCE, + XtreamPlaylistData, +} from '../data-sources/xtream-data-source.interface'; +import { XtreamApiService } from '../services/xtream-api.service'; +import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { XtreamUrlService } from '../services/xtream-url.service'; +import { XtreamXmltvFallbackService } from '../services/xtream-xmltv-fallback.service'; +import { XtreamStore } from './xtream.store'; + +const PLAYLIST: XtreamPlaylistData = { + id: 'playlist-b', + name: 'Portal B', + serverUrl: 'http://portal-b.example', + username: 'demo', + password: 'secret', + type: 'xtream', +}; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, reject, resolve }; +} + +async function waitForCondition( + predicate: () => boolean, + attempts = 20 +): Promise { + for (let index = 0; index < attempts; index += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + throw new Error('Timed out waiting for test condition'); +} + +function configureStore( + xtreamApiService: { + getVodInfo: jest.Mock; + getVodStream: jest.Mock; + }, + dataSource = { + getAllCategories: jest + .fn() + .mockResolvedValue([{ id: 7, xtream_id: 701 }]), + getCategories: jest.fn().mockResolvedValue([]), + } +) { + TestBed.configureTestingModule({ + providers: [ + XtreamStore, + { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, + { provide: XtreamApiService, useValue: xtreamApiService }, + { provide: DatabaseService, useValue: {} }, + { provide: PlaylistsService, useValue: {} }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest.fn(() => jest.fn()), + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsEpg: false }, + }, + { + provide: SettingsStore, + useValue: { + preferUploadedEpgOverXtream: jest.fn(() => false), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: jest.fn(() => false) }, + }, + { provide: XtreamPendingRestoreService, useValue: {} }, + { provide: XtreamUrlService, useValue: {} }, + { provide: XtreamXmltvFallbackService, useValue: {} }, + { provide: PORTAL_PLAYER, useValue: {} }, + ], + }); + + const store = TestBed.inject(XtreamStore); + store.setCurrentPlaylist(PLAYLIST); + return store; +} + +describe('XtreamStore VOD details recovery', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('publishes sparse details before upgrading them from the catalog', async () => { + const catalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn().mockResolvedValue({ info: [] }), + getVodStream: jest.fn(() => catalogItem.promise), + }; + const store = configureStore(xtreamApiService); + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + + expect(store.selectedItem()).toEqual( + expect.objectContaining({ + info: [], + stream_id: 42, + container_extension: null, + }) + ); + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + + const publishedSelection = store.selectedItem(); + if (!publishedSelection) { + throw new Error('Expected the sparse selection to be published'); + } + store.setSelectedItem({ + ...publishedSelection, + info: { name: 'Enriched while recovery was pending' }, + }); + catalogItem.resolve({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.containerExtension === 'mkv' + ); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 42, + containerExtension: 'mkv', + }); + expect(store.selectedItem()?.info).toEqual({ + name: 'Enriched while recovery was pending', + }); + }); + + it('drops a catalog upgrade after a newer detail request starts', async () => { + const firstCatalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn((_credentials: unknown, vodId: string) => + Promise.resolve( + vodId === '42' + ? { info: [] } + : { + info: [], + stream_id: 99, + container_extension: 'mp4', + } + ) + ), + getVodStream: jest.fn(() => firstCatalogItem.promise), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.streamId === 99 + ); + + firstCatalogItem.resolve({ + stream_id: 42, + container_extension: 'mkv', + } as XtreamVodStream); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 99, + containerExtension: 'mp4', + }); + }); + + it('keeps the fallback usable as a page when catalog recovery fails', async () => { + const catalogItem = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn().mockResolvedValue({ info: [] }), + getVodStream: jest.fn(() => catalogItem.promise), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + await waitForCondition( + () => xtreamApiService.getVodStream.mock.calls.length === 1 + ); + catalogItem.reject(new Error('Catalog unavailable')); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.selectedItem()).toEqual( + expect.objectContaining({ + info: [], + stream_id: 42, + container_extension: null, + }) + ); + expect(store.detailsError()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index d16aa5160..7a807cc3e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -1,16 +1,16 @@ import { computed, inject } from '@angular/core'; import { signalStore, withComputed, withMethods } from '@ngrx/signals'; -import { XtreamSerieDetails, XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { + XtreamSerieDetails, + XtreamVodDetails, +} from '@iptvnator/shared/interfaces'; -// Import existing features that are already separate import { withFavorites } from '../with-favorites.feature'; import { withRecentItems } from '../with-recent-items'; -// Import service import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; import { XtreamApiService } from '../services/xtream-api.service'; -// Import new feature stores import { TmdbEnrichmentService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; import { @@ -27,25 +27,13 @@ import { enrichSerialSelectionWithTmdb, enrichVodSelectionWithTmdb, } from './xtream-tmdb-enrichment'; +import { + applyRecoveredXtreamVodCatalogItem, + createXtreamDetailsRequestGuard, + resolveXtreamVodDetailsSelection, +} from './xtream-details-request'; -/** - * XtreamStore - Facade composing all feature stores. - * - * This store provides a unified API for components while delegating - * to specialized feature stores for different concerns: - * - * - withPortal: Playlist and portal status management - * - withContent: Categories and streams management - * - withSelection: UI selection and pagination - * - withSearch: Search functionality - * - withEpg: EPG (Electronic Program Guide) data - * - withPlayer: Stream URL construction and player integration - * - withFavorites: Favorites management - * - withRecentItems: Recently viewed items - * - withPlaybackPositions: Playback position tracking - * - * @see docs/XTREAM_STORE_REFACTORING_PLAN.md - */ +/** Facade composing the Xtream feature stores and cross-feature workflows. */ export const XtreamStore = signalStore( { providedIn: 'root' }, @@ -60,7 +48,6 @@ export const XtreamStore = signalStore( withRecentItems(), withPlaybackPositions(), - // Cross-feature computed properties withComputed((store) => ({ /** * Get global recent items (from withRecentItems) @@ -76,21 +63,20 @@ export const XtreamStore = signalStore( const dataSource = inject(XTREAM_DATA_SOURCE); const tmdbEnrichment = inject(TmdbEnrichmentService); const logger = createLogger('XtreamStore'); - const findVodCatalogItem = (vodId: string | number) => - store.vodStreams().find((item) => { - const candidateId = - item.xtream_id ?? - item.stream_id ?? - (item as { id?: string | number }).id; - - return Number(candidateId) === Number(vodId); - }); - + const detailsRequestGuard = createXtreamDetailsRequestGuard( + () => store.currentPlaylist()?.id + ); return { + cancelDetailsRequest(): void { + detailsRequestGuard.invalidate(); + store.setIsLoadingDetails(false); + }, + /** * Full store reset for switching between playlists */ resetStore(newPlaylistId?: string): void { + detailsRequestGuard.invalidate(); // Clear the session cache for the playlist we're leaving so // stale data cannot bleed into the new playlist (PWA path). const leavingPlaylistId = store.playlistId(); @@ -148,38 +134,80 @@ export const XtreamStore = signalStore( }): void { const playlist = store.currentPlaylist(); if (!playlist) return; + const isCurrentRequest = detailsRequestGuard.begin(playlist.id); + const credentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }; store.setIsLoadingDetails(true); store.setDetailsError(null); xtreamApiService - .getVodInfo( - { - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - }, - params.vodId - ) + .getVodInfo(credentials, params.vodId) .then((vodDetails: XtreamVodDetails) => { - const catalogItem = findVodCatalogItem(params.vodId); + if (!isCurrentRequest()) return; + const { recovery, selection } = + resolveXtreamVodDetailsSelection({ + apiService: xtreamApiService, + currentCategories: store.vodCategories(), + currentCategoriesPlaylistId: + store.vodCategoriesPlaylistId(), + currentStreams: store.vodStreams(), + currentStreamsPlaylistId: + store.vodStreamsPlaylistId(), + credentials, + dataSource, + isCurrent: isCurrentRequest, + playlistId: playlist.id, + routeCategoryId: params.categoryId, + vodDetails, + vodId: params.vodId, + }); + if (!isCurrentRequest()) return; store.setSelectedCategory(params.categoryId); - store.setSelectedItem({ - ...catalogItem, - ...vodDetails, - stream_id: params.vodId, - xtream_id: - catalogItem?.xtream_id ?? Number(params.vodId), - }); - // Async, best-effort: patches the selection with a - // field-level TMDB merge once metadata arrives + store.setSelectedItem(selection); void enrichVodSelectionWithTmdb( store, tmdbEnrichment, params.vodId ); + + if (!recovery) { + return; + } + + void recovery.then( + ({ recoveredCatalogItem, recoveryError }) => { + if (!isCurrentRequest()) { + return; + } + if (recoveryError) { + logger.warn( + 'Failed to recover sparse VOD playback source from the catalog', + recoveryError + ); + return; + } + if (!recoveredCatalogItem) { + return; + } + + const recoveredSelection = + applyRecoveredXtreamVodCatalogItem( + store.selectedItem(), + recoveredCatalogItem, + params.vodId + ); + if (recoveredSelection) { + store.setSelectedItem(recoveredSelection); + } + } + ); }) .catch((error: unknown) => { + if (!isCurrentRequest()) return; logger.error('Error fetching VOD details', error); store.setDetailsError( error instanceof Error @@ -188,7 +216,9 @@ export const XtreamStore = signalStore( ); }) .finally(() => { - store.setIsLoadingDetails(false); + if (isCurrentRequest()) { + store.setIsLoadingDetails(false); + } }); }, @@ -202,19 +232,19 @@ export const XtreamStore = signalStore( }): void { const playlist = store.currentPlaylist(); if (!playlist) return; + const isCurrentRequest = detailsRequestGuard.begin(playlist.id); + const credentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }; store.setIsLoadingDetails(true); store.setDetailsError(null); xtreamApiService - .getSeriesInfo( - { - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - }, - params.serialId - ) + .getSeriesInfo(credentials, params.serialId) .then((serialDetails: XtreamSerieDetails) => { + if (!isCurrentRequest()) return; store.setSelectedCategory(params.categoryId); store.setSelectedItem({ ...serialDetails, @@ -227,6 +257,7 @@ export const XtreamStore = signalStore( ); }) .catch((error: unknown) => { + if (!isCurrentRequest()) return; logger.error('Error fetching series details', error); store.setDetailsError( error instanceof Error @@ -235,7 +266,9 @@ export const XtreamStore = signalStore( ); }) .finally(() => { - store.setIsLoadingDetails(false); + if (isCurrentRequest()) { + store.setIsLoadingDetails(false); + } }); }, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 78c2a02b6..c7692464c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -88,6 +88,7 @@ describe('SerialDetailsComponent', () => { password: 'pass', }); const fetchSerialDetailsWithMetadata = jest.fn(); + const cancelDetailsRequest = jest.fn(); const checkFavoriteStatus = jest.fn(); const constructEpisodeStreamUrl = jest.fn(); const addRecentItem = jest.fn(); @@ -142,6 +143,7 @@ describe('SerialDetailsComponent', () => { isLoadingDetails.set(false); detailsError.set(null); fetchSerialDetailsWithMetadata.mockClear(); + cancelDetailsRequest.mockClear(); checkFavoriteStatus.mockClear(); constructEpisodeStreamUrl.mockReset(); constructEpisodeStreamUrl.mockImplementation( @@ -192,6 +194,7 @@ describe('SerialDetailsComponent', () => { detailsError, currentPlaylist, fetchSerialDetailsWithMetadata, + cancelDetailsRequest, checkFavoriteStatus, setSelectedItem: jest.fn((value: unknown) => selectedItem.set(value) @@ -347,6 +350,12 @@ describe('SerialDetailsComponent', () => { }); }); + it('invalidates an in-flight detail request on teardown', () => { + fixture.componentInstance.ngOnDestroy(); + + expect(cancelDetailsRequest).toHaveBeenCalledTimes(1); + }); + it('renders series metadata when backdrop_path is absent at runtime', () => { selectedItem.set({ series_id: 103, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index edb4362bf..e4bbb488e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -302,6 +302,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.xtreamStore.cancelDetailsRequest(); this.playback.closeInlinePlayer(); this.xtreamStore.setSelectedItem(null); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index fd0eb00a3..f16e46121 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -1,10 +1,9 @@ import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { DownloadsService } from '@iptvnator/services'; -import { - XtreamVodDetails, - getXtreamVodInfo, -} from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access'; +import { XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; /** * Offline downloads for the movie on screen. @@ -83,22 +82,27 @@ export class VodDetailsDownloadsService { return; } - const info = getXtreamVodInfo(vodItem); + // A provider can omit the route id entirely (sparse details), and the + // resolved source is then the only place the stream id exists. + const source = resolveXtreamVodPlaybackSource(vodItem); + if (!source) { + return; + } + + const presentation = resolveXtreamVodPlaybackPresentation(vodItem); const routeVodId = this.routeContentId(); - const id = Number.isFinite(routeVodId) - ? routeVodId - : Number( - vodItem.movie_data?.stream_id || - (vodItem as { stream_id?: number }).stream_id - ); + const id = + Number.isSafeInteger(routeVodId) && routeVodId > 0 + ? routeVodId + : source.streamId; await this.downloadsService.startDownload({ playlistId: playlist.id, xtreamId: id, contentType: 'vod', - title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', + title: presentation.title, url: this.xtreamStore.constructVodStreamUrl(vodItem), - posterUrl: info?.movie_image, + posterUrl: presentation.posterUrl, headers: { userAgent: playlist.userAgent, referer: playlist.referrer, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts index 2826d6676..515caa99b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts @@ -225,7 +225,11 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { // The user presses Play on the route copy while that close is pending. service.playVod({ - movie_data: { stream_id: ROUTE_VOD_ID, name: 'Example Movie' }, + movie_data: { + stream_id: ROUTE_VOD_ID, + name: 'Example Movie', + container_extension: 'mp4', + }, } as never); releaseClose?.(); await switching; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts new file mode 100644 index 000000000..f3fd91cad --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback-presentation.ts @@ -0,0 +1,47 @@ +import { + getXtreamVodInfo, + XtreamVodDetails, +} from '@iptvnator/shared/interfaces'; + +type XtreamVodPresentationItem = XtreamVodDetails & { + readonly name?: string; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; +}; + +export interface XtreamVodPlaybackPresentation { + readonly posterUrl?: string; + readonly title: string; +} + +export function resolveXtreamVodPlaybackPresentation( + vodItem: XtreamVodDetails +): XtreamVodPlaybackPresentation { + const item = vodItem as XtreamVodPresentationItem; + const info = getXtreamVodInfo(vodItem); + + return { + title: + firstText( + info?.name, + item.movie_data?.name, + item.title, + item.name + ) ?? 'Unknown', + posterUrl: firstText( + info?.movie_image, + info?.cover_big, + item.poster_url, + item.stream_icon + ), + }; +} + +function firstText(...values: unknown[]): string | undefined { + const value = values.find( + (candidate) => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + return typeof value === 'string' ? value.trim() : undefined; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index feb90c3a7..953a65168 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -15,7 +15,10 @@ import { createLogger, getPortalPlaybackProgressPercent, } from '@iptvnator/portal/shared/util'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + resolveXtreamVodPlaybackSource, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { PlaybackPositionData, @@ -23,7 +26,6 @@ import { ResolvedPortalPlayback, XtreamVodDetails, XtreamVodInfo, - getXtreamVodInfo, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; import { @@ -31,6 +33,7 @@ import { ownsContent, runningExternalSession, } from './vod-details-external-session'; +import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; import { formatPlaybackPosition } from './vod-primary-action-position'; export interface VodDetailsPlaybackBindings { @@ -63,9 +66,7 @@ export class VodDetailsPlaybackService { private readonly logger = createLogger('VodDetailsPlayback'); /** Signals bound from the host component via `bind()` */ - private readonly bindings = signal( - null - ); + private readonly bindings = signal(null); readonly inlinePlayback = signal(null); /** @@ -175,22 +176,26 @@ export class VodDetailsPlaybackService { return; } + const source = resolveXtreamVodPlaybackSource(vodItem); + if (!source) { + return; + } + const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) { return; } - const info = getXtreamVodInfo(vodItem); + const presentation = resolveXtreamVodPlaybackPresentation(vodItem); this.addToRecentlyViewed(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const routeVodId = this.bindings()?.vodId(); const id = - routeVodId != null && Number.isFinite(routeVodId) + routeVodId != null && + Number.isSafeInteger(routeVodId) && + routeVodId > 0 ? routeVodId - : Number( - vodItem.movie_data?.stream_id || - (vodItem as { stream_id?: number }).stream_id - ); + : source.streamId; this.logger.debug('playVod resolved ID', { id, vodItem }); @@ -201,8 +206,8 @@ export class VodDetailsPlaybackService { }; const playback: ResolvedPortalPlayback = { streamUrl, - title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', - thumbnail: info?.movie_image, + title: presentation.title, + thumbnail: presentation.posterUrl, contentInfo, }; @@ -214,14 +219,30 @@ export class VodDetailsPlaybackService { return; } + const source = resolveXtreamVodPlaybackSource(vodItem); + if (!source) { + return; + } + const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) { return; } - const info = getXtreamVodInfo(vodItem); + const presentation = resolveXtreamVodPlaybackPresentation(vodItem); this.addToRecentlyViewed(); - const vodId = this.bindings()?.vodId() ?? NaN; + // Master's sparse-details fallback: a provider that omits the route + // id still has the stream id on the resolved source. + const routeVodId = this.bindings()?.vodId(); + const vodId = + routeVodId != null && + Number.isSafeInteger(routeVodId) && + routeVodId > 0 + ? routeVodId + : source.streamId; + // The ROUTE copy's row, not the last position seen: Resume starts the + // route's stream, and an alternative's timecode belongs to a + // different (playlist, stream) key. const position = this.routePlaybackPosition(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); @@ -232,8 +253,8 @@ export class VodDetailsPlaybackService { }; const playback: ResolvedPortalPlayback = { streamUrl, - title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', - thumbnail: info?.movie_image, + title: presentation.title, + thumbnail: presentation.posterUrl, startTime: position?.positionSeconds, contentInfo, }; @@ -376,7 +397,6 @@ export class VodDetailsPlaybackService { this.startPlayback(playback); } - private startPlayback(playback: ResolvedPortalPlayback): void { // EVERY start claims the generation, not just the switch path. Play, // Resume and Restart reach here directly, and a switch still waiting diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 30568be54..3f7ef705e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -225,6 +225,28 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(playback.hasPlaybackPosition()).toBe(false); }); + it('downloads the movie the route currently shows', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + fixture.detectChanges(); + + await fixture.componentInstance.downloadVod({ + // A DIFFERENT id in the payload: the route's id must win. + movie_data: { + stream_id: 111, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + // The id comes from the route params SIGNAL, not `snapshot.params`: + // the router reuses this component for detail-to-detail navigation + // (the Similar rail), and the snapshot still names the film the user + // came from — so the download would fetch the wrong movie. + expect(stubs.startDownload).toHaveBeenCalledWith( + expect.objectContaining({ xtreamId: 650020 }) + ); + }); + it('replaces an alternative’s timecode when Resume starts the route copy', async () => { currentPlaylist.set({ id: 'playlist-1' }); const component = fixture.componentInstance; @@ -246,7 +268,11 @@ describe('VodDetailsRouteComponent — playback actions', () => { // timeupdate resolves the next source at a position from another copy. component.multiSource.reportPosition(4200); component.resumeVod({ - movie_data: { stream_id: 650020, name: 'Example' }, + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, } as never); expect(reported).toHaveBeenLastCalledWith(120); @@ -265,7 +291,11 @@ describe('VodDetailsRouteComponent — playback actions', () => { const reported = jest.spyOn(component.multiSource, 'reportPosition'); await component.onPrimaryAction({ - movie_data: { stream_id: 650020, name: 'Example' }, + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, } as never); expect(reported).not.toHaveBeenCalled(); @@ -283,7 +313,11 @@ describe('VodDetailsRouteComponent — playback actions', () => { // Reaching the service directly would skip the bookkeeping a route // start needs — the controller would keep an alternative's timecode. void component.onPrimaryAction({ - movie_data: { stream_id: 650020, name: 'Example' }, + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, } as never); return Promise.resolve().then(() => { @@ -347,7 +381,11 @@ describe('VodDetailsRouteComponent — playback actions', () => { .mockResolvedValue('played'); await component.restartVod({ - movie_data: { stream_id: 650020, name: 'Example' }, + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, } as never); expect(pinnedPlay).toHaveBeenCalled(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts new file mode 100644 index 000000000..0db3123b6 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -0,0 +1,361 @@ +import { Location } from '@angular/common'; +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarService, + DownloadsService, + PlaybackPositionRuntimeBridgeService, + SettingsStore, +} from '@iptvnator/services'; +import { + PlaybackPositionData, + XtreamCategory, + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { NEVER, of } from 'rxjs'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + template: '
', +}) +class StubPortalInlinePlayerComponent { + readonly playback = input(null); + // Multi-source wiring: the real player takes these, so the stub has to + // accept them or the template binding fails to compile. + readonly alternativeSources = input([]); + readonly allSources = input([]); + readonly sourcesMatchKind = input(null); + readonly sourcesAutoFailoverEnabled = input(false); + readonly sourcesAutoFailoverSupported = input(true); + readonly sourcesPlaybackLive = input(false); + readonly sourcesAutoFailoverToggled = output(); + readonly alternativeSourceRequested = output(); + readonly sourcePinRequested = output(); + readonly sourceCheckRequested = output(); + readonly playbackFailed = output(); + readonly timeUpdate = output(); + readonly closed = output(); + readonly backClicked = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); +} +type SparseVodItem = XtreamVodDetails & { + readonly container_extension: string; + readonly name: string; + readonly stream_icon: string; + readonly stream_id: number; +}; +describe('VodDetailsRouteComponent fallback actions', () => { + let fixture: ComponentFixture; + const selectedItem = signal(null); + const downloadsAvailable = signal(false); + const isFavorite = signal(false); + const downloads = signal([]); + const currentPlaylist = signal({ + id: 'playlist-1', + userAgent: 'IPTVnator', + referrer: 'https://referrer.example', + origin: 'https://origin.example', + }); + const vodStreams = signal[]>([]); + const vodCategories = signal[]>([]); + const vodStreamsPlaylistId = signal('playlist-1'); + const vodCategoriesPlaylistId = signal('playlist-1'); + const constructVodStreamUrl = jest + .fn() + .mockReturnValue('http://example.com/movie/650020.mp4'); + const isEmbeddedPlayer = jest.fn().mockReturnValue(true); + const openResolvedPlayback = jest.fn(); + const startDownload = jest.fn().mockResolvedValue(undefined); + const toggleFavorite = jest.fn(); + const sparseItem = (): SparseVodItem => ({ + info: [], + stream_id: 650020, + container_extension: 'mp4', + name: 'Catalog movie', + stream_icon: 'https://example.com/catalog-poster.jpg', + }); + beforeEach(async () => { + selectedItem.set(null); + downloadsAvailable.set(false); + isFavorite.set(false); + currentPlaylist.set({ + id: 'playlist-1', + userAgent: 'IPTVnator', + referrer: 'https://referrer.example', + origin: 'https://origin.example', + }); + vodStreams.set([]); + vodCategories.set([]); + vodStreamsPlaylistId.set('playlist-1'); + vodCategoriesPlaylistId.set('playlist-1'); + constructVodStreamUrl.mockClear(); + isEmbeddedPlayer.mockReset().mockReturnValue(true); + openResolvedPlayback.mockClear(); + startDownload.mockClear(); + toggleFavorite.mockClear(); + await TestBed.configureTestingModule({ + imports: [VodDetailsRouteComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ + vodId: '650020', + categoryId: '235', + }), + snapshot: { + params: { + vodId: '650020', + categoryId: '235', + }, + }, + }, + }, + { + provide: Router, + useValue: { navigate: jest.fn() }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: NEVER, + onTranslationChange: NEVER, + onDefaultLangChange: NEVER, + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem, + isLoadingDetails: signal(false), + detailsError: signal(null), + isFavorite, + currentPlaylist, + vodStreams, + vodCategories, + vodStreamsPlaylistId, + vodCategoriesPlaylistId, + fetchVodDetailsWithMetadata: jest.fn(), + cancelDetailsRequest: jest.fn(), + checkFavoriteStatus: jest.fn(), + setSelectedItem: jest.fn(), + toggleFavorite, + constructVodStreamUrl, + addRecentItem: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { + isAvailable: downloadsAvailable, + downloads, + isDownloaded: jest.fn().mockReturnValue(false), + isDownloading: jest.fn().mockReturnValue(false), + isPaused: jest.fn().mockReturnValue(false), + resumeDownloadByContent: jest.fn(), + startDownload, + getDownloadedFilePath: jest.fn(), + playDownload: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer, + openResolvedPlayback, + openExternalPlayback: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + closeSession: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest.fn(), + }, + }, + { + provide: CrossPortalSimilarService, + useValue: { + isAvailable: false, + buildLink: jest.fn(), + matchRecommendations: jest.fn(), + }, + }, + { + provide: SettingsStore, + useValue: { theme: signal('dark') }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: Location, useValue: { back: jest.fn() } }, + ], + }) + .overrideComponent(VodDetailsRouteComponent, { + remove: { imports: [PortalInlinePlayerComponent] }, + add: { imports: [StubPortalInlinePlayerComponent] }, + }) + .compileComponents(); + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + it('ignores catalog data owned by another playlist', () => { + currentPlaylist.set({ + id: 'playlist-b', + userAgent: 'IPTVnator', + referrer: 'https://referrer.example', + origin: 'https://origin.example', + }); + vodStreamsPlaylistId.set('playlist-a'); + vodCategoriesPlaylistId.set('playlist-a'); + vodStreams.set([ + { + name: 'Stale catalog title', + stream_id: 650020, + container_extension: 'mkv', + }, + ]); + vodCategories.set([ + { + category_id: '235', + category_name: 'Stale category', + }, + ]); + + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedCatalogItem()).toBeNull(); + expect(fixture.componentInstance.selectedCategory()).toBeNull(); + }); + it('starts playable sparse VOD inline with catalog presentation', () => { + const item = sparseItem(); + selectedItem.set(item); + downloadsAvailable.set(true); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelector('button.play-btn')).not.toBeNull(); + expect(host.querySelector('button.favorite-btn')).not.toBeNull(); + expect(host.querySelector('button.download-btn')).not.toBeNull(); + host.querySelector('button.play-btn')?.click(); + fixture.detectChanges(); + expect(constructVodStreamUrl).toHaveBeenCalledWith(item); + const inlinePlayer = fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + ).componentInstance as StubPortalInlinePlayerComponent; + expect(inlinePlayer.playback()).toEqual( + expect.objectContaining({ + streamUrl: 'http://example.com/movie/650020.mp4', + title: 'Catalog movie', + thumbnail: 'https://example.com/catalog-poster.jpg', + }) + ); + expect( + host.querySelector('app-portal-detail-shell')?.classList + ).toContain('shell-host--watch'); + }); + it('reuses the sparse item for resume, favorite, and download', async () => { + const item = sparseItem(); + selectedItem.set(item); + downloadsAvailable.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + const storedPosition = { + contentXtreamId: 650020, + contentType: 'vod', + playlistId: 'playlist-1', + positionSeconds: 42, + durationSeconds: 120, + } satisfies PlaybackPositionData; + // Both, as `loadPosition` does: the Resume button reads the ROUTE + // copy's row, while `vodPlaybackPosition` follows whichever copy last + // reported — multi-source can put those on different streams. + fixture.componentInstance.vodPlaybackPosition.set(storedPosition); + fixture.componentInstance.routePlaybackPosition.set(storedPosition); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + host.querySelector( + 'button.play-btn--resume' + )?.click(); + fixture.detectChanges(); + host.querySelector('button.favorite-btn')?.click(); + host.querySelector('button.download-btn')?.click(); + await fixture.whenStable(); + + expect(constructVodStreamUrl).toHaveBeenNthCalledWith(1, item); + expect(constructVodStreamUrl).toHaveBeenNthCalledWith(2, item); + expect(toggleFavorite).toHaveBeenCalledWith( + '650020', + 'playlist-1', + 'movie', + undefined + ); + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Catalog movie', + posterUrl: 'https://example.com/catalog-poster.jpg', + url: 'http://example.com/movie/650020.mp4', + }) + ); + const inlinePlayer = fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + ).componentInstance as StubPortalInlinePlayerComponent; + expect(inlinePlayer.playback()).toEqual( + expect.objectContaining({ startTime: 42 }) + ); + }); + + it('keeps external playback in the browse shell', () => { + const item = sparseItem(); + selectedItem.set(item); + isEmbeddedPlayer.mockReturnValue(false); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + host.querySelector('button.play-btn')?.click(); + fixture.detectChanges(); + + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Catalog movie', + thumbnail: 'https://example.com/catalog-poster.jpg', + }), + true + ); + expect( + fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + ) + ).toBeNull(); + expect( + host.querySelector('app-portal-detail-shell')?.classList + ).not.toContain('shell-host--watch'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 538852e24..f32ebd009 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -1,3 +1,184 @@ + + @if (playableVodItem(); as playableItem) { + @if (hasPlaybackPosition()) { + + @if (!isExternalStopAction()) { + + } + } @else { + + } + @if (!isFavorite()) { + + } @else { + + } + @if (isElectron()) { + @if (isDownloaded()) { + + } @else if (isPausedDownload()) { + + } @else if (isDownloading()) { + + } @else { + + } + } + + @if (multiSource.hasAlternatives()) { + + } + + @if (activeSourceCaption(); as caption) { +

+ + {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} + + {{ caption.source }} + @if (multiSource.hasAlternatives()) { + + @if (caption.alternativeCount === 1) { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' + | translate + }} + } @else { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER' + | translate + : { count: caption.alternativeCount } + }} + } + + } +

+ } + } +
+ + + @if (playableVodItem()) { + @if (inlinePlayback(); as playback) { + + } + } + + @let item = selectedItem(); @if (selectedVodInfo(); as info) { {{ 'XTREAM.ACTORS' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + @for ( + member of info.tmdb_cast; + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (info.tmdb_directors?.length) {
- @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + @for ( + member of info.tmdb_directors; + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { - - - @if (externalPrimaryLabel(); as label) { - {{ label }} - } @else { - {{ 'XTREAM.RESUME' | translate }} - {{ formatPosition() }} - } - - - @if (!isExternalStopAction()) { - - } - } @else { - - } - @if (multiSource.hasAlternatives()) { - - } - @if (!isFavorite()) { - - } @else { - - } - @if (isElectron()) { - @if (isDownloaded()) { - - } @else if (isPausedDownload()) { - - } @else if (isDownloading()) { - - } @else { - - } - } - @if (activeSourceCaption(); as caption) { -

- - {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} - - {{ - caption.source - }} - @if (multiSource.hasAlternatives()) { - - @if (caption.alternativeCount === 1) { - {{ - 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' - | translate - }} - } @else { - {{ - 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER' - | translate - : { count: caption.alternativeCount } - }} - } - - } -

- } + - - @if (inlinePlayback(); as playback) { - - } +
@if (trailerEmbedUrl(); as trailerUrl) { @@ -362,15 +382,16 @@ } @for ( item of similarInPortals(); - track 'x' + item.match.playlistId + '-' + item.match.xtreamId + track 'x' + + item.match.playlistId + + '-' + + item.match.xtreamId ) {