Merge master into the VOD multi-source branch

Master's #1303 (keep sparse VOD details playable) landed in the same four
files this branch restructured, so the merge is a real one rather than a
fast-forward.

What was reconciled:

- The template: master extracted the actions and the inline player into named
  `ng-template`s reached through `ngTemplateOutlet`, so multi-source moved
  into those templates rather than staying inline — the sources chip, the
  "Playing from" caption, the pin-aware Restart, and the player's source
  wiring.
- `downloadVod` and `similarItems`: master's sparse-source handling and its
  playlist-scoped catalog moved INTO the services this branch extracted them
  into, rather than back onto the component.
- Resume keeps reading the ROUTE copy's position row while master's sparse-id
  fallback supplies the stream id, since those answer different questions.
- The route spec: master's version is kept whole, including its four new
  fallback-action tests; this branch's download test moved to the spec that
  uses the shared harness.

Three of master's tests needed the multi-source inputs added to their inline
player stub, and one had to set the route-scoped position signal as
`loadPosition` does — the Resume button reads that row, not the last position
seen from any copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 08:34:25 +02:00
commit 3fa7007592
75 files changed
+4161 -615

No files matched your search

+7
View File
@@ -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.
+1 -1
View File
@@ -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 -- <why> */`; 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 -- <why> */`; 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/<project>/**/*.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 <project> -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.
+33 -10
View File
@@ -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 -- <why> */`; 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/<project>/**/*.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
@@ -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<void>;
process(): ElectronChildProcess;
}
export interface ElectronExitConfirmationOptions {
readonly closeTimeoutMs: number;
readonly exitTimeoutMs: number;
}
export interface PrepareElectronApplicationOptions<Application, Prepared> {
readonly application: Application;
readonly dispose: (application: Application) => Promise<void>;
readonly prepare: (application: Application) => Promise<Prepared>;
}
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<Application, Prepared>(
options: PrepareElectronApplicationOptions<Application, Prepared>
): Promise<Prepared> {
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<void> {
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<void>;
} {
let resolveExit: (() => void) | undefined;
const onExit = () => resolveExit?.();
const promise = new Promise<void>((resolvePromise) => {
resolveExit = resolvePromise;
child.once('exit', onExit);
if (hasExited(child)) resolvePromise();
});
return {
cancel: () => child.removeListener('exit', onExit),
promise,
};
}
async function resolvesWithin(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((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<void>,
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');
}
}
@@ -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<void> {
await closeElectronApplicationAndConfirmExit(app.electronApp, {
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
});
}
async function waitForPromiseWithTimeout(
promise: Promise<unknown>,
timeoutMs: number
@@ -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';
@@ -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',
@@ -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<void>(() => 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<void>(() => undefined);
},
process: () => child,
};
await Promise.race([
closeElectronApplicationAndConfirmExit(application, {
closeTimeoutMs: 1_000,
exitTimeoutMs: 10,
}),
new Promise<never>((_, 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<void>(() => undefined),
process: () => child,
};
await closeElectronApplicationAndConfirmExit(application, {
closeTimeoutMs: 1,
exitTimeoutMs: 10,
});
assert.equal(child.killCalls, 1);
assert.equal(child.signalCode, 'SIGTERM');
});
});
@@ -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';
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */
import type {
CancellationBenchmarkManifest,
CancellationBenchmarkSummary,
@@ -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';
@@ -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';
@@ -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<number>;
}
async function disposeXtreamAppResource(
resource: XtreamAppResource
): Promise<void> {
await closeElectronAppAndConfirmExit(resource.app);
await removeXtreamDataDirectory(resource.dataDirectory);
}
async function createXtreamAppResource(
configuration: XtreamBenchmarkConfiguration
): Promise<XtreamAppResource> {
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<void> {
return rm(dataDirectory, {
force: true,
maxRetries: 20,
recursive: true,
retryDelay: 250,
});
}
async function prepareXtreamAppResource(
resource: XtreamAppResource,
options: LaunchXtreamBenchmarkAppOptions
): Promise<PreparedXtreamApp> {
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<LaunchedXtreamBenchmarkApp> {
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<void> {
await disposeXtreamAppResource({ app, dataDirectory });
}
@@ -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<ReturnType<XtreamControlClient['state']>> | 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
);
}
}
}
@@ -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,
@@ -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<void>,
propagatedFailure: XtreamPropagatedFailure | null
): Promise<void> {
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 }
);
}
}
@@ -61,12 +61,24 @@ export interface XtreamReportFixture {
readonly iterations: readonly XtreamValidatedIterationResult[];
}
export async function formalReportFixture(): Promise<XtreamReportFixture> {
return reportFixture(createXtreamIterationDefinitions(false), 10, true);
export async function formalReportFixture(
startupRetryIndex: number | null = null
): Promise<XtreamReportFixture> {
return reportFixture(
createXtreamIterationDefinitions(false),
10,
true,
startupRetryIndex
);
}
export async function smokeReportFixture(): Promise<XtreamReportFixture> {
return reportFixture(createXtreamIterationDefinitions(true), 100, false);
return reportFixture(
createXtreamIterationDefinitions(true),
100,
false,
null
);
}
export async function clearXtreamProvenanceFixtureDirectories(): Promise<void> {
@@ -80,7 +92,8 @@ export async function clearXtreamProvenanceFixtureDirectories(): Promise<void> {
async function reportFixture(
definitions: readonly XtreamIterationDefinition[],
identityOffset: number,
mixedInitialOverlap: boolean
mixedInitialOverlap: boolean,
startupRetryIndex: number | null
): Promise<XtreamReportFixture> {
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),
@@ -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}`);
}
@@ -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];
@@ -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
)
)
);
}
@@ -136,6 +136,8 @@ export function createXtreamAssemblerFixture(
profileDirectorySha256: String(scenarioOrdinal(scenarioId)).repeat(
64
),
startupAttemptCount: 1,
startupRetryReasons: [],
},
rendererCapture,
terminal: createTerminal(raw, background),
@@ -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
@@ -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();
@@ -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(
@@ -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/
);
});
});
@@ -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');
}
@@ -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,
@@ -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', () => {
@@ -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
);
});
});
@@ -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<FakeResource, string>({
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<FakeResource, string>({
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<FakeResource, string>({
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<FakeResource, string>({
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
);
});
});
@@ -0,0 +1,92 @@
import {
XTREAM_STARTUP_RETRY_REASON,
type XtreamStartupRetryReason,
} from './xtream-summary-contract';
export interface XtreamStartupRetryOptions<Resource, Prepared> {
readonly classifyFailure: (
failure: unknown
) => XtreamStartupRetryReason | null;
readonly create: (attempt: number) => Promise<Resource>;
readonly dispose: (resource: Resource) => Promise<void>;
readonly maxAttempts: number;
readonly prepare: (
resource: Resource,
attempt: number
) => Promise<Prepared>;
}
export interface XtreamStartupRetryResult<Resource, Prepared> {
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<Resource>(
dispose: (resource: Resource) => Promise<void>,
failure: unknown,
resource: Resource
): Promise<void> {
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<Resource, Prepared>(
options: XtreamStartupRetryOptions<Resource, Prepared>
): Promise<XtreamStartupRetryResult<Resource, Prepared>> {
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');
}
@@ -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 {
@@ -25,6 +25,7 @@ export type XtreamLiveStream = {
export type XtreamVodStream = {
added?: string;
category_id?: string;
container_extension?: string;
name?: string;
stream_id?: number | string;
};
@@ -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);
}
+15
View File
@@ -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
+6 -3
View File
@@ -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.
@@ -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
+20 -2
View File
@@ -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 },
],
},
},
@@ -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';
@@ -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) => {
@@ -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<XtreamVodStream[]> {
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<XtreamVodStream | null> {
const streams = await this.getVodStreams(credentials, {
...options,
categoryId,
});
return (
streams.find(
(stream) => Number(stream.stream_id) === Number(vodId)
) ?? null
);
}
/**
* Get series items
*/
@@ -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,
@@ -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}`;
}
/**
@@ -0,0 +1,72 @@
import { XtreamVodDetails } from '@iptvnator/shared/interfaces';
import {
resolveXtreamVodPlaybackSource,
XtreamVodPlaybackItem,
} from './xtream-vod-playback-source';
function createMovieDataDetails(
overrides: Partial<NonNullable<XtreamVodDetails['movie_data']>> = {}
): 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();
});
});
@@ -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)
);
}
@@ -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<any[]>(),
@@ -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<any[]>();
const cachedContent = createDeferred<any[]>();
@@ -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<XtreamVodStream>(
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) {
@@ -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();
});
});
@@ -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<XtreamVodStream | null>((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,
});
});
});
@@ -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<XtreamVodStream | null>;
};
readonly currentCategories: readonly XtreamVodCatalogCategory[];
readonly credentials: XtreamCredentials;
readonly dataSource: {
getAllCategories(
playlistId: string,
type: 'movies'
): Promise<readonly XtreamVodCatalogCategory[]>;
getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: 'vod'
): Promise<readonly XtreamVodCatalogCategory[]>;
};
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<XtreamVodDetailsRecoveryResult> | 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<XtreamVodStream | null> {
if (!isCurrent()) {
return null;
}
const attemptedCategoryIds = new Set<number>();
const findInCategories = async (
categories: readonly XtreamVodCatalogCategory[]
): Promise<XtreamVodStream | null> => {
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,
};
}
@@ -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<typeof buildXtreamVodSelection>[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<typeof buildXtreamVodSelection>[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();
});
});
@@ -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<number>();
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;
}
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
async function waitForCondition(
predicate: () => boolean,
attempts = 20
): Promise<void> {
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<XtreamVodStream | null>();
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<XtreamVodStream | null>();
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<XtreamVodStream | null>();
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);
});
});
@@ -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);
}
});
},
@@ -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,
@@ -302,6 +302,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.playback.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
@@ -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,
@@ -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;
@@ -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;
}
@@ -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<VodDetailsPlaybackBindings | null>(
null
);
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(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
@@ -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();
@@ -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: '<div data-testid="inline-vod-player"></div>',
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(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<unknown>([]);
readonly allSources = input<unknown>([]);
readonly sourcesMatchKind = input<unknown>(null);
readonly sourcesAutoFailoverEnabled = input(false);
readonly sourcesAutoFailoverSupported = input(true);
readonly sourcesPlaybackLive = input(false);
readonly sourcesAutoFailoverToggled = output<boolean>();
readonly alternativeSourceRequested = output<string>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly backClicked = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
}
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<VodDetailsRouteComponent>;
const selectedItem = signal<XtreamVodDetails | null>(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<Partial<XtreamVodStream>[]>([]);
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
const vodStreamsPlaylistId = signal<string | null>('playlist-1');
const vodCategoriesPlaylistId = signal<string | null>('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<HTMLButtonElement>('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<HTMLButtonElement>(
'button.play-btn--resume'
)?.click();
fixture.detectChanges();
host.querySelector<HTMLButtonElement>('button.favorite-btn')?.click();
host.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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');
});
});
@@ -1,3 +1,184 @@
<ng-template #vodActions>
@if (playableVodItem(); as playableItem) {
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction(playableItem)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }}
{{ formatPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="restartVod(playableItem)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction(playableItem)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
<button
class="download-btn download-btn--completed"
(click)="playFromLocal()"
>
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button
class="download-btn"
(click)="downloadVod(playableItem)"
>
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[playbackLive]="playbackLive()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{ caption.source }}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
}
</p>
}
}
</ng-template>
<ng-template #inlinePlayer>
@if (playableVodItem()) {
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
[sourcesMatchKind]="multiSource.matchKind()"
[sourcesAutoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[sourcesAutoFailoverSupported]="autoFailoverSupported()"
[sourcesPlaybackLive]="playbackLive()"
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
(playbackFailed)="onPlaybackFailed()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
}
}
</ng-template>
@let item = selectedItem();
@if (selectedVodInfo(); as info) {
<app-portal-detail-shell
@@ -37,7 +218,12 @@
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@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
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -81,10 +267,17 @@
}
@if (info.director || info.tmdb_directors?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
@if (info.tmdb_directors?.length) {
<div class="details__cast-strip">
@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
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
@@ -129,182 +322,9 @@
</ng-template>
<ng-template appDetailActions>
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }}
{{ formatPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="restartVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
externalPrimaryIcon()
}}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[playbackLive]="playbackLive()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron()) {
@if (isDownloaded()) {
<button
class="download-btn download-btn--completed"
(click)="playFromLocal()"
>
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isPausedDownload()) {
<button
class="download-btn download-btn--paused"
(click)="resumePausedDownload()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'DOWNLOADS.RESUME' | translate }}
</button>
} @else if (isDownloading()) {
<button
class="download-btn download-btn--downloading"
disabled
>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="downloadVod(item)">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{
caption.source
}}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
}
</p>
}
<ng-container [ngTemplateOutlet]="vodActions" />
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
[sourcesMatchKind]="multiSource.matchKind()"
[sourcesAutoFailoverEnabled]="
multiSource.autoFailoverEnabled()
"
[sourcesAutoFailoverSupported]="autoFailoverSupported()"
[sourcesPlaybackLive]="playbackLive()"
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
(playbackFailed)="onPlaybackFailed()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
(streamUrlCopied)="showCopyNotification()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
/>
}
<ng-container detail-player [ngTemplateOutlet]="inlinePlayer" />
<div detail-extras>
@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
) {
<button
type="button"
class="details__similar-card"
[title]="
item.title +
' — ' +
item.match.playlistName
item.title + ' — ' + item.match.playlistName
"
(click)="openSimilarInPortals(item)"
>
@@ -405,7 +426,9 @@
<app-portal-detail-shell
[title]="fallback.title"
[posterUrl]="fallback.posterUrl"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-template appDetailTags>
<span
@@ -447,6 +470,11 @@
</div>
}
</ng-template>
<ng-template appDetailActions>
<ng-container [ngTemplateOutlet]="vodActions" />
</ng-template>
<ng-container detail-player [ngTemplateOutlet]="inlinePlayer" />
</app-portal-detail-shell>
} @else if (detailsError()) {
<app-portal-detail-shell
@@ -1,54 +1,231 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { NEVER, of } from 'rxjs';
import { Location } from '@angular/common';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { VodDetailsRouteComponent } from './vod-details-route.component';
describe('VodDetailsRouteComponent', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
activeSession,
addRecentItem,
checkFavoriteStatus,
closeSession,
constructVodStreamUrl,
currentPlaylist,
detailsError,
fetchVodDetailsWithMetadata,
getPlaybackPosition,
isFavorite,
isLoadingDetails,
selectedItem,
setSelectedItem,
toggleFavorite,
vodCategories,
vodStreams,
} = stubs;
let consoleDebugSpy: jest.SpyInstance | undefined;
let consoleWarnSpy: jest.SpyInstance | undefined;
const selectedItem = signal<XtreamVodDetails | null>(null);
const isLoadingDetails = signal(false);
const detailsError = signal<string | null>(null);
const isFavorite = signal(false);
const currentPlaylist = signal<{
id: string;
userAgent?: string;
referrer?: string;
origin?: string;
} | null>(null);
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
const vodStreamsPlaylistId = signal<string | null>(null);
const vodCategoriesPlaylistId = signal<string | null>(null);
const fetchVodDetailsWithMetadata = jest.fn();
const cancelDetailsRequest = jest.fn();
const checkFavoriteStatus = jest.fn();
const setSelectedItem = jest.fn();
const toggleFavorite = jest.fn();
const constructVodStreamUrl = jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4');
const addRecentItem = jest.fn();
const downloads = signal([]);
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
const consoleDebug = console.debug.bind(console);
const consoleWarn = console.warn.bind(console);
consoleDebugSpy = jest
.spyOn(console, 'debug')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' ||
args[0] === '[VodDetailsPlayback]'
) {
return;
}
consoleDebug(...args);
});
consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' &&
args[1] === 'Deferring VOD details init: playlist not ready'
) {
return;
}
consoleWarn(...args);
});
selectedItem.set(null);
isLoadingDetails.set(false);
detailsError.set(null);
isFavorite.set(false);
currentPlaylist.set(null);
vodStreams.set([]);
vodCategories.set([]);
vodStreamsPlaylistId.set(null);
vodCategoriesPlaylistId.set(null);
fetchVodDetailsWithMetadata.mockClear();
cancelDetailsRequest.mockClear();
checkFavoriteStatus.mockClear();
setSelectedItem.mockClear();
toggleFavorite.mockClear();
constructVodStreamUrl.mockClear();
addRecentItem.mockClear();
getPlaybackPosition.mockClear();
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({
vodId: '650020',
categoryId: '235',
}),
snapshot: {
params: {
vodId: '650020',
categoryId: '235',
},
},
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: NEVER,
onTranslationChange: NEVER,
onDefaultLangChange: NEVER,
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: {
selectedItem,
isLoadingDetails,
detailsError,
isFavorite,
currentPlaylist,
vodStreams,
vodCategories,
vodStreamsPlaylistId,
vodCategoriesPlaylistId,
fetchVodDetailsWithMetadata,
cancelDetailsRequest,
checkFavoriteStatus,
setSelectedItem,
toggleFavorite,
constructVodStreamUrl,
addRecentItem,
},
},
{
provide: SettingsStore,
useValue: {
theme: signal('dark'),
},
},
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
startDownload: jest.fn(),
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
closeSession: jest.fn(),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition,
savePlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: Location,
useValue: {
back: jest.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
consoleDebugSpy?.mockRestore();
consoleWarnSpy?.mockRestore();
});
it('renders an informational fallback without playback controls when Xtream returns empty metadata', () => {
it('keeps the fallback visible and exposes actions when catalog playback fields are usable', () => {
selectedItem.set({
info: [],
} as XtreamVodDetails);
stream_id: 650020,
name: 'Die Kühe sind Los! (2004) DE',
stream_icon: 'https://example.com/cows.jpg',
container_extension: 'mp4',
} as XtreamVodDetails & {
container_extension: string;
name: string;
stream_icon: string;
stream_id: number;
});
vodStreams.set([
{
name: 'Die Kühe sind Los! (2004) DE',
@@ -80,9 +257,59 @@ describe('VodDetailsRouteComponent', () => {
host.querySelector('[data-testid="xtream-vod-fallback-status"]')
?.textContent
).toContain('XTREAM.DETAIL_FALLBACK.STATUS');
expect(host.querySelector('button.play-btn')).not.toBeNull();
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
expect(host.querySelector('button.download-btn')).toBeNull();
});
it('keeps the fallback visible for a minimal info object', () => {
selectedItem.set({
info: {
name: 'Only a provider title',
},
stream_id: 650020,
name: 'Catalog title',
container_extension: 'mp4',
} as unknown as XtreamVodDetails);
vodStreams.set([
{
name: 'Catalog title',
stream_id: 650020,
container_extension: 'mp4',
},
]);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')
).not.toBeNull();
expect(host.querySelector('button.play-btn')).not.toBeNull();
});
it('reveals fallback actions when async catalog recovery adds a source', () => {
selectedItem.set({
info: [],
stream_id: 650020,
container_extension: null,
} as unknown as XtreamVodDetails);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('button.play-btn')).toBeNull();
expect(host.querySelector('button.favorite-btn')).toBeNull();
expect(host.querySelector('button.download-btn')).toBeNull();
selectedItem.set({
info: [],
stream_id: 650020,
container_extension: 'mkv',
} as unknown as XtreamVodDetails);
fixture.detectChanges();
expect(host.querySelector('button.play-btn')).not.toBeNull();
expect(host.querySelector('button.favorite-btn')).not.toBeNull();
});
it('keeps the full Xtream detail view when usable metadata exists', () => {
@@ -164,20 +391,9 @@ describe('VodDetailsRouteComponent', () => {
expect(hero.backdropUrl()).toBeUndefined();
});
it('downloads the movie the route currently shows', async () => {
currentPlaylist.set({ id: 'playlist-1' });
fixture.detectChanges();
it('invalidates an in-flight detail request on teardown', () => {
fixture.componentInstance.ngOnDestroy();
await fixture.componentInstance.downloadVod({
movie_data: { stream_id: 111, name: 'Example' },
} 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 })
);
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { Location, SlicePipe } from '@angular/common';
import { Location, NgTemplateOutlet, SlicePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -23,11 +23,11 @@ import {
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
createLogger,
PORTAL_PLAYBACK_POSITIONS,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
@@ -67,10 +67,6 @@ import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import {
createPrimaryActionPosition,
formatPlaybackPosition,
} from './vod-primary-action-position';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -91,6 +87,7 @@ import {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
NgTemplateOutlet,
PortalDetailShellComponent,
SafePipe,
SlicePipe,
@@ -110,7 +107,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly msUi = inject(VodDetailsMultiSourceUiService);
@@ -122,6 +118,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly backdropBackfillKey = signal<string | null>(null);
readonly inlinePlayback = this.playback.inlinePlayback;
readonly vodPlaybackPosition = this.playback.vodPlaybackPosition;
/** The route copy's own row — what Resume acts on. */
readonly routePlaybackPosition = this.playback.routePlaybackPosition;
/**
* Reactive route params: the component is reused when navigating
@@ -141,6 +139,20 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null
);
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodCategoriesPlaylistId() === playlistId
? this.xtreamStore.vodCategories()
: [];
});
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.routeParams().categoryId;
if (!categoryId) {
@@ -148,7 +160,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
return (
this.xtreamStore.vodCategories().find(
this.scopedVodCategories().find(
(category) =>
String(
(
@@ -180,7 +192,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
return (
this.xtreamStore.vodStreams().find(
this.scopedVodStreams().find(
(item) =>
Number(
(
@@ -224,6 +236,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
@@ -405,6 +421,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.playback.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
@@ -52,6 +52,8 @@ export function createVodDetailsRouteStubs() {
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4'),
addRecentItem: jest.fn(),
cancelDetailsRequest: jest.fn(),
vodStreamsPlaylistId: signal<string | null>(null),
downloads: signal([]),
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
@@ -174,6 +176,8 @@ export async function configureVodDetailsRouteTestBed(
toggleFavorite: stubs.toggleFavorite,
constructVodStreamUrl: stubs.constructVodStreamUrl,
addRecentItem: stubs.addRecentItem,
cancelDetailsRequest: stubs.cancelDetailsRequest,
vodStreamsPlaylistId: stubs.vodStreamsPlaylistId,
},
},
{
@@ -48,6 +48,18 @@ export class VodDetailsSimilarService {
this.routeContentId = bindings.routeContentId;
}
/**
* The catalog only when it belongs to the playlist on screen — otherwise
* a rail would match against another portal's streams.
*/
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
/** TMDB recommendations matched against the loaded VOD catalog. */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.vodInfo();
@@ -57,7 +69,7 @@ export class VodDetailsSimilarService {
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.xtreamStore.vodStreams(),
this.scopedVodStreams(),
{ excludeId: this.routeContentId() }
);
});
@@ -26,6 +26,7 @@ describe('XtreamCollectionDetailComponent', () => {
let selectedItem: ReturnType<typeof signal<unknown>>;
let isLoadingDetails: ReturnType<typeof signal<boolean>>;
let detailsError: ReturnType<typeof signal<string | null>>;
let cancelDetailsRequest: jest.Mock;
beforeEach(async () => {
playlistId = signal('');
@@ -35,6 +36,7 @@ describe('XtreamCollectionDetailComponent', () => {
selectedItem = signal<unknown>(null);
isLoadingDetails = signal(false);
detailsError = signal<string | null>(null);
cancelDetailsRequest = jest.fn();
await TestBed.configureTestingModule({
imports: [XtreamCollectionDetailComponent],
@@ -49,6 +51,7 @@ describe('XtreamCollectionDetailComponent', () => {
selectedItem,
isLoadingDetails,
detailsError,
cancelDetailsRequest,
setPlaylistId: jest.fn((value: string) =>
playlistId.set(value)
),
@@ -158,4 +161,10 @@ describe('XtreamCollectionDetailComponent', () => {
?.get(XTREAM_SERIES_RESUME_TARGET)()
).toEqual(seriesResume);
});
it('invalidates detail loading before restoring the underlying store', () => {
fixture.componentInstance.ngOnDestroy();
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
});
@@ -87,6 +87,7 @@ export class XtreamCollectionDetailComponent {
}
ngOnDestroy(): void {
this.xtreamStore.cancelDetailsRequest();
this.restoreStoreState();
}
+6
View File
@@ -277,6 +277,12 @@
"validationCommand": "pnpm nx test release-tools",
"reason": "Node tests validate release-note parsing, rendering and gate policy; the scripts are release tooling, not shipped source."
},
{
"name": "eslint-tools",
"root": "tools/eslint",
"validationCommand": "pnpm nx test eslint-tools",
"reason": "Node tests assert the committed max-lines baseline still matches what the generator produces; the scripts are lint tooling, not shipped source, and percentage coverage over a generated list would not mean anything."
},
{
"name": "shared-marketing-fixtures",
"root": "libs/shared/marketing-fixtures",
+108 -39
View File
@@ -5,6 +5,13 @@
* files that already exceed the max-lines limit enforced in eslint.config.mjs.
* Baselined files are exempt from the rule; the list should only shrink.
*
* The offender check runs ESLint's own `max-lines` rule through `Linter`,
* against a config mirroring the real one (production vs test ceiling, shared
* options), rather than counting lines here. Reimplementing the count would
* silently disagree with the rule the moment either side changed — a `//`
* inside a template literal is enough — and a baseline that disagrees with the
* rule turns CI red while looking correct.
*
* Files that carry their own file-wide `eslint-disable max-lines` comment are
* skipped: they are already exempt with a written justification next to the
* code, which is the preferred escape hatch for a file that genuinely cannot
@@ -13,16 +20,60 @@
* Usage: node tools/eslint/generate-max-lines-baseline.mjs
*/
import { Linter } from 'eslint';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import tsParser from '@typescript-eslint/parser';
export const MAX_LINES = 400;
import {
MAX_LINES_OPTIONS,
MAX_LINES_PROD,
MAX_LINES_TEST,
TEST_FILE_GLOBS,
} from './max-lines-config.mjs';
const workspaceRoot = process.cwd();
// Resolved from this file rather than cwd, so importing the module from a test
// (or running the script from a subdirectory) scans the same tree either way.
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..'
);
const scanRoots = ['apps', 'libs', 'tools'];
const skipDirs = new Set(['node_modules', 'dist', '.nx', 'coverage']);
/**
* Mirrors the two max-lines blocks in eslint.config.mjs. The baseline block is
* deliberately absent — an already-baselined file must still report here, or
* the list could never be re-derived.
*/
const lintConfig = [
{
files: ['**/*.ts', '**/*.tsx'],
ignores: TEST_FILE_GLOBS,
languageOptions: { parser: tsParser },
rules: {
'max-lines': [
'error',
{ max: MAX_LINES_PROD, ...MAX_LINES_OPTIONS },
],
},
},
{
files: TEST_FILE_GLOBS,
languageOptions: { parser: tsParser },
rules: {
'max-lines': [
'error',
{ max: MAX_LINES_TEST, ...MAX_LINES_OPTIONS },
],
},
},
];
const linter = new Linter();
function collectTsFiles(dir, results) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
@@ -39,14 +90,6 @@ function collectTsFiles(dir, results) {
return results;
}
function countLines(content) {
if (content.length === 0) {
return 0;
}
const lines = content.split('\n').length;
return content.endsWith('\n') ? lines - 1 : lines;
}
/**
* True when the file already suppresses max-lines with a file-wide
* `/* eslint-disable *\/` comment. Those files are deliberately exempt with a
@@ -66,46 +109,72 @@ function hasInlineMaxLinesDisable(content) {
if (rules === '') {
return true;
}
if (
rules
.split(',')
.some((rule) => rule.trim() === 'max-lines')
) {
if (rules.split(',').some((rule) => rule.trim() === 'max-lines')) {
return true;
}
}
return false;
}
const offenders = scanRoots
.flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), []))
.map((filePath) => {
const content = readFileSync(filePath, 'utf8');
return {
file: path
.relative(workspaceRoot, filePath)
.split(path.sep)
.join('/'),
lines: countLines(content),
disabled: hasInlineMaxLinesDisable(content),
};
})
.filter(({ lines, disabled }) => lines > MAX_LINES && !disabled)
.sort((a, b) => a.file.localeCompare(b.file));
/** Repo-relative POSIX path — what the flat-config globs are matched against. */
function toRelativePosix(filePath) {
return path.relative(workspaceRoot, filePath).split(path.sep).join('/');
}
const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand.
// TypeScript files that predate the max-lines (${MAX_LINES}) ESLint rule.
function exceedsLimit(content, relativePath) {
const messages = linter.verify(content, lintConfig, relativePath);
return messages.some((message) => message.ruleId === 'max-lines');
}
/**
* The repo-relative paths that currently exceed their bucket's limit, sorted.
* Exported so a test can assert the committed baseline still matches, which is
* what keeps stale entries and forgotten regenerations out of the list.
*/
export function computeOffenders() {
return scanRoots
.flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), []))
.map((filePath) => {
const content = readFileSync(filePath, 'utf8');
return { file: toRelativePosix(filePath), content };
})
.filter(
({ content, file }) =>
!hasInlineMaxLinesDisable(content) &&
exceedsLimit(content, file)
)
.map(({ file }) => file)
.sort((a, b) => a.localeCompare(b));
}
/** The exact file contents for a given offender list. */
export function renderBaseline(offenders) {
const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand.
// TypeScript files that predate the max-lines ESLint rule (${MAX_LINES_PROD} for
// production code, ${MAX_LINES_TEST} for tests; blank lines and comments are not counted).
// This list should only shrink: split a file below the limit, rerun the
// generator, and commit the result. Never add new files here.
// A new file that genuinely cannot be split takes a file-wide
// \`/* eslint-disable max-lines -- <why> */\` instead; the generator skips those.
`;
const body = offenders.map((file) => ` '${file}',`).join('\n');
return `${banner}export const maxLinesBaseline = [\n${body}\n];\n`;
}
const body = offenders.map(({ file }) => ` '${file}',`).join('\n');
writeFileSync(
path.join(workspaceRoot, 'tools/eslint/max-lines-baseline.mjs'),
`${banner}export const maxLinesBaseline = [\n${body}\n];\n`
export const BASELINE_PATH = path.join(
workspaceRoot,
'tools/eslint/max-lines-baseline.mjs'
);
console.log(`Baselined ${offenders.length} files over ${MAX_LINES} lines.`);
// Only write when run as a script — importing this module must not mutate the
// baseline it is used to verify.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const offenders = computeOffenders();
writeFileSync(BASELINE_PATH, renderBaseline(offenders));
console.log(
`Baselined ${offenders.length} files (prod > ${MAX_LINES_PROD}, test > ${MAX_LINES_TEST}).`
);
}
+2 -59
View File
@@ -1,134 +1,77 @@
// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand.
// TypeScript files that predate the max-lines (400) ESLint rule.
// TypeScript files that predate the max-lines ESLint rule (400 for
// production code, 1200 for tests; blank lines and comments are not counted).
// This list should only shrink: split a file below the limit, rerun the
// generator, and commit the result. Never add new files here.
// A new file that genuinely cannot be split takes a file-wide
// `/* eslint-disable max-lines -- <why> */` instead; the generator skips those.
export const maxLinesBaseline = [
'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts',
'apps/electron-backend-e2e/src/category-management.e2e.ts',
'apps/electron-backend-e2e/src/electron-test-fixtures.ts',
'apps/electron-backend-e2e/src/favorites.e2e.ts',
'apps/electron-backend-e2e/src/playlist-switcher.e2e.ts',
'apps/electron-backend-e2e/src/portal-mock-fixtures.ts',
'apps/electron-backend-e2e/src/recent.e2e.ts',
'apps/electron-backend-e2e/src/search.e2e.ts',
'apps/electron-backend-e2e/src/settings.e2e.ts',
'apps/electron-backend-e2e/src/sources.e2e.ts',
'apps/electron-backend/src/app/api/main.preload.spec-data.ts',
'apps/electron-backend/src/app/api/main.preload.ts',
'apps/electron-backend/src/app/app.ts',
'apps/electron-backend/src/app/database/operations/content.operations.spec.ts',
'apps/electron-backend/src/app/database/operations/content.operations.ts',
'apps/electron-backend/src/app/database/operations/playlist.operations.ts',
'apps/electron-backend/src/app/events/epg-query.service.spec.ts',
'apps/electron-backend/src/app/events/epg-query.service.ts',
'apps/electron-backend/src/app/events/epg-worker.service.ts',
'apps/electron-backend/src/app/events/epg.events.spec.ts',
'apps/electron-backend/src/app/events/mpv-session.service.ts',
'apps/electron-backend/src/app/events/player.events.spec.ts',
'apps/electron-backend/src/app/events/playlist.events.spec.ts',
'apps/electron-backend/src/app/events/vlc-session.service.ts',
'apps/electron-backend/src/app/services/app-update.service.spec.ts',
'apps/electron-backend/src/app/services/app-update.service.ts',
'apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts',
'apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts',
'apps/electron-backend/src/app/services/embedded-mpv-native.service.ts',
'apps/electron-backend/src/app/util/validated-axios.spec.ts',
'apps/electron-backend/src/app/workers/database.worker.ts',
'apps/electron-backend/src/app/workers/epg-parser.worker.ts',
'apps/stalker-mock-server/src/app/data-generator.ts',
'apps/web-backend/src/app/web-backend-app.spec.ts',
'apps/web-backend/src/app/web-backend-app.ts',
'apps/web-e2e/src/stalker.e2e.ts',
'apps/web-e2e/src/xtream.e2e.ts',
'apps/web/src/app/services/electron.service.ts',
'apps/web/src/app/services/pwa.service.ts',
'apps/xtream-mock-server/src/app/generators/marketing.generator.ts',
'libs/epg/data-access/src/lib/epg.service.spec.ts',
'libs/epg/data-access/src/lib/epg.service.ts',
'libs/m3u-state/src/lib/effects.ts',
'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts',
'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts',
'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts',
'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts',
'libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts',
'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts',
'libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts',
'libs/playlist/shared/util/src/lib/playlist-context.facade.ts',
'libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts',
'libs/portal/downloads/feature/src/lib/downloads.component.ts',
'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts',
'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',
'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts',
'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts',
'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts',
'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts',
'libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts',
'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts',
'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts',
'libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts',
'libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts',
'libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts',
'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts',
'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts',
'libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts',
'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts',
'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts',
'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts',
'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts',
'libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts',
'libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts',
'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts',
'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts',
'libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts',
'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts',
'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts',
'libs/services/src/lib/database-electron.service.ts',
'libs/services/src/lib/downloads.service.ts',
'libs/services/src/lib/playlist-backup.service.ts',
'libs/services/src/lib/playlists.service.spec.ts',
'libs/services/src/lib/playlists.service.ts',
'libs/services/src/lib/runtime-capabilities.service.spec.ts',
'libs/shared/database/src/lib/connection.spec.ts',
'libs/shared/database/src/lib/connection.ts',
'libs/shared/interfaces/src/lib/electron-api.interface.ts',
'libs/shared/m3u-utils/src/lib/playlist.utils.ts',
'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts',
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts',
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
'libs/ui/components/src/lib/season-container/season-container.component.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',
'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts',
'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts',
'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts',
'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts',
'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts',
'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts',
'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts',
'tools/release/generate-marketing-artwork.ts',
];
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, it } from 'node:test';
import { maxLinesBaseline } from './max-lines-baseline.mjs';
import {
BASELINE_PATH,
computeOffenders,
renderBaseline,
} from './generate-max-lines-baseline.mjs';
import {
MAX_LINES_OPTIONS,
MAX_LINES_PROD,
MAX_LINES_TEST,
TEST_FILE_GLOBS,
} from './max-lines-config.mjs';
describe('max-lines baseline', () => {
it('matches what the generator produces', () => {
// The whole point of the baseline is that it lists files which really do
// exceed the limit. A stale entry silently exempts a file that was
// already split, and a missing entry turns CI red on an unrelated PR.
assert.equal(
readFileSync(BASELINE_PATH, 'utf8'),
renderBaseline(computeOffenders()),
'Baseline is out of date — run: node tools/eslint/generate-max-lines-baseline.mjs'
);
});
it('never lists a file twice', () => {
assert.equal(
new Set(maxLinesBaseline).size,
maxLinesBaseline.length,
'baseline contains duplicate entries'
);
});
it('stays sorted so regenerating produces a reviewable diff', () => {
assert.deepEqual(
maxLinesBaseline,
[...maxLinesBaseline].sort((a, b) => a.localeCompare(b))
);
});
});
describe('max-lines config', () => {
it('gives tests more headroom than production code', () => {
// A spec is a flat list of independent cases; holding it to the
// production ceiling produces arbitrary `-2.spec.ts` splits.
assert.ok(
MAX_LINES_TEST > MAX_LINES_PROD,
`expected test ceiling (${MAX_LINES_TEST}) above production (${MAX_LINES_PROD})`
);
});
it('does not count comments or blank lines', () => {
// This repo mandates documentation, so a docblock must never be the
// reason a file has to be split.
assert.equal(MAX_LINES_OPTIONS.skipComments, true);
assert.equal(MAX_LINES_OPTIONS.skipBlankLines, true);
});
it('routes specs, e2e specs and e2e apps to the test ceiling', () => {
for (const glob of [
'**/*.spec.ts',
'**/*.e2e.ts',
'apps/*-e2e/**/*.ts',
]) {
assert.ok(
TEST_FILE_GLOBS.includes(glob),
`expected ${glob} among the test globs`
);
}
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Single source of truth for the max-lines rule.
*
* Both eslint.config.mjs (which enforces the rule) and
* generate-max-lines-baseline.mjs (which lists the files that predate it) import
* from here, so the enforced limit and the generated baseline can never drift
* apart.
*/
/**
* Production TypeScript. CLAUDE.md asks for under 300 lines; this is the hard
* ceiling CI enforces.
*/
export const MAX_LINES_PROD = 400;
/**
* Tests and test infrastructure. A spec is a flat list of independent cases:
* splitting one at the production limit yields arbitrary `-2.spec.ts` files and
* makes coverage harder to find, and a long spec signals thorough coverage
* rather than the design debt the production limit is meant to catch. The
* ceiling is still bounded so a runaway fixture gets noticed.
*/
export const MAX_LINES_TEST = 1200;
/**
* Comments and blank lines are not counted. This repo mandates documentation
* (see "Documentation After Changes" in CLAUDE.md), so a docblock must never be
* the reason a file has to be split.
*/
export const MAX_LINES_OPTIONS = {
skipBlankLines: true,
skipComments: true,
};
/**
* Specs, E2E specs, and everything inside the E2E apps — the latter covers
* fixtures, harnesses and performance capture helpers, which are test
* infrastructure even though they carry no `.spec`/`.e2e` suffix.
*/
export const TEST_FILE_GLOBS = [
'**/*.spec.ts',
'**/*.e2e.ts',
'apps/*-e2e/**/*.ts',
];
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "eslint-tools",
"projectType": "library",
"sourceRoot": "tools/eslint",
"targets": {
"test": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{workspaceRoot}/tools/eslint/max-lines-config.mjs",
"{workspaceRoot}/tools/eslint/max-lines-baseline.mjs",
"{workspaceRoot}/tools/eslint/generate-max-lines-baseline.mjs",
"{workspaceRoot}/tools/eslint/max-lines-baseline.test.mjs",
"{workspaceRoot}/apps/**/*.ts",
"{workspaceRoot}/libs/**/*.ts",
"{workspaceRoot}/tools/**/*.ts"
],
"options": {
"command": "node --test tools/eslint/max-lines-baseline.test.mjs",
"cwd": "{workspaceRoot}"
}
},
"lint": {
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tools/eslint/**/*"
],
"command": "eslint \"tools/eslint/*.mjs\""
}
},
"tags": ["scope:tools", "domain:lint", "type:tool"]
}