perf(playback): keep the history gate off the initial bundle

The `@iptvnator/services` barrel ships in the initial bundle, so adding
PlaybackHistoryGate there (and subscribing to it from the app-wide
ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes.

- Move the gate to a new lazy-only `playback-data-access` project
  (`@iptvnator/playback/data-access`; scope:shared, domain:playback,
  type:data-access) and register it in the coverage policy.
- The gate subscribes to MPV/VLC session updates itself; it is created by
  the first deferred write, which precedes the launch it waits for.
  ExternalPlaybackService is back to master.
- The Xtream "playlist switched before confirmation" check moves to the
  lazy helper; the initial-path store only takes a `skipListRefresh` flag.

Net effect on this branch: +27 bytes over master (master itself is
108 bytes over the ratchet baseline already).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 13:41:31 +02:00
1 parent 671a0cb1ec
commit e41bcb9f65
34 files changed
+250 -128

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'playback-data-access',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/playback/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "playback-data-access",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playback/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/playback/data-access/jest.config.ts",
"tsConfig": "libs/playback/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/playback-history-gate.service';
@@ -0,0 +1,209 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { PlaybackHistoryGate } from './playback-history-gate.service';
describe('PlaybackHistoryGate', () => {
let gate: PlaybackHistoryGate;
beforeEach(() => {
gate = new PlaybackHistoryGate();
});
it('holds a write until its playback is confirmed', () => {
const commit = jest.fn();
gate.defer(
{ sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] },
commit
);
expect(commit).not.toHaveBeenCalled();
gate.confirm({ sessionKey: 'live:p1:c1' });
expect(commit).toHaveBeenCalledTimes(1);
});
it('never commits a write whose stream is not confirmed', () => {
const failed = jest.fn();
const played = jest.fn();
gate.defer({ streamUrls: ['http://stream/failed'] }, failed);
gate.defer({ streamUrls: ['http://stream/played'] }, played);
gate.confirm({ streamUrls: ['http://stream/played'] });
expect(failed).not.toHaveBeenCalled();
expect(played).toHaveBeenCalledTimes(1);
});
it('does not let the same URL played in another playlist confirm a write', () => {
// Playlist A's attempt failed; the same stream then plays in B.
const failedInA = jest.fn();
const playedInB = jest.fn();
gate.defer(
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
failedInA
);
gate.defer(
{ sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] },
playedInB
);
gate.confirm({
sessionKey: 'live:b:c9',
streamUrls: ['http://shared/1'],
});
expect(failedInA).not.toHaveBeenCalled();
expect(playedInB).toHaveBeenCalledTimes(1);
});
it('does not let a URL-only confirmation commit a write deferred with a session key', () => {
// Playlist A's attempt failed; the same URL then opens in MPV/VLC,
// whose app-wide session confirmation carries no session key.
const failedInA = jest.fn();
gate.defer(
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
failedInA
);
gate.confirm({ streamUrls: ['http://shared/1'] });
expect(failedInA).not.toHaveBeenCalled();
});
it('matches writes without a session key by stream URL', () => {
const portalWrite = jest.fn();
const externalWrite = jest.fn();
gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite);
gate.defer({ streamUrls: ['http://portal/vod'] }, externalWrite);
// An inline player (with its own key) and an MPV/VLC session.
gate.confirm({
sessionKey: 'live:portal:9',
streamUrls: ['http://portal/tmp'],
});
gate.confirm({ streamUrls: ['http://portal/vod'] });
expect(portalWrite).toHaveBeenCalledTimes(1);
expect(externalWrite).toHaveBeenCalledTimes(1);
});
it('commits each confirmed write once', () => {
const commit = jest.fn();
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
gate.confirm({ streamUrls: ['http://stream/1'] });
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(commit).toHaveBeenCalledTimes(1);
});
it('commits every writer deferred for the same playback', () => {
const first = jest.fn();
const second = jest.fn();
gate.defer({ streamUrls: ['http://stream/1'] }, first);
gate.defer({ streamUrls: ['http://stream/1'] }, second);
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it('commits immediately when a write has nothing to match on', () => {
const commit = jest.fn();
gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit);
expect(commit).toHaveBeenCalledTimes(1);
});
it('ignores confirmations with nothing to match on', () => {
const commit = jest.fn();
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
gate.confirm({ streamUrls: [undefined, ''] });
expect(commit).not.toHaveBeenCalled();
});
it('drops the oldest unconfirmed writes beyond its bound', () => {
const oldest = jest.fn();
gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest);
for (let index = 0; index < 20; index += 1) {
gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn());
}
gate.confirm({ streamUrls: ['http://stream/oldest'] });
expect(oldest).not.toHaveBeenCalled();
});
describe('MPV/VLC sessions', () => {
const originalElectron = window.electron;
let emit: (session: Partial<ExternalPlayerSession>) => void;
beforeEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
onExternalPlayerSessionUpdate: (
callback: (session: ExternalPlayerSession) => void
) => {
emit = (session) =>
callback({
streamUrl: 'http://vod/1.mkv',
...session,
} as ExternalPlayerSession);
return () => undefined;
},
},
});
gate = new PlaybackHistoryGate();
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: originalElectron,
});
});
it('confirms a stream once its external player has opened', () => {
const commit = jest.fn();
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
emit({ status: 'launching' });
expect(commit).not.toHaveBeenCalled();
emit({ status: 'opened' });
expect(commit).toHaveBeenCalledTimes(1);
});
it('does not confirm a launch that failed', () => {
const commit = jest.fn();
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
emit({ status: 'launching' });
emit({ status: 'error' });
expect(commit).not.toHaveBeenCalled();
});
});
it('keeps committing the other writes when one throws', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const failing = jest.fn(() => {
throw new Error('db down');
});
const healthy = jest.fn();
gate.defer({ streamUrls: ['http://stream/1'] }, failing);
gate.defer({ streamUrls: ['http://stream/1'] }, healthy);
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(healthy).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
});
});
@@ -0,0 +1,130 @@
import { Injectable } from '@angular/core';
/** What a playback is known by, for correlating a write with its playback. */
export interface PlaybackHistoryTarget {
/**
* The playing host's `playbackSessionKey` (source and content scoped).
* A write deferred with one is only confirmed by that same key.
*/
readonly sessionKey?: string | null;
/** Stream URLs; what a write without a session key is matched by. */
readonly streamUrls?: readonly (string | null | undefined)[];
}
interface NormalizedTarget {
readonly sessionKey: string | null;
readonly streamUrls: ReadonlySet<string>;
}
interface PendingHistoryWrite {
readonly target: NormalizedTarget;
readonly commit: () => void;
}
/**
* Upper bound on unconfirmed writes. Each click on a channel that never
* plays leaves one behind; the oldest is dropped rather than kept forever.
*/
const MAX_PENDING_HISTORY_WRITES = 20;
/**
* Holds "recently viewed" writes back until the stream really plays.
*
* The code that resolves a channel or movie is not the code that plays it:
* a Stalker link is resolved in the store, played by whichever view mounts
* the player, or handed to MPV/VLC. Writers therefore `defer` the write under
* what the playback will be known by, and whoever observes the playback
* `confirm`s it: the inline players once the stream has advanced for a
* couple of seconds, and the external-player session as soon as MPV/VLC is
* launched. A stream that fails before that point never reaches history or
* the dashboard hero.
*
* A write deferred with a session key is only confirmed by the same key: the
* same stream URL can sit in two playlists, and playing it in one — inline
* or in MPV/VLC, whose app-wide confirmation knows only the URL — must not
* record a failed attempt in the other. Writers that cannot know the
* playing host's key (portal resolvers, collection tabs) defer by stream
* URL, which any confirmation of that URL matches.
* Several writers may defer for the same playback; one confirmation commits
* all of them. A write with nothing to match on cannot be confirmed and is
* committed immediately, as before this gate existed.
*/
@Injectable({ providedIn: 'root' })
export class PlaybackHistoryGate {
private pending: PendingHistoryWrite[] = [];
constructor() {
// MPV/VLC cannot report whether a live stream plays, so a launch
// that opened is the confirmation. Subscribed here rather than in
// the app-wide external-playback service so the gate stays off the
// initial bundle: it is created by the first deferred write, which
// always precedes the launch it waits for.
window.electron?.onExternalPlayerSessionUpdate?.((session) => {
if (session.status === 'opened' || session.status === 'playing') {
this.confirm({ streamUrls: [session.streamUrl] });
}
});
}
defer(target: PlaybackHistoryTarget, commit: () => void): void {
const normalized = normalizeTarget(target);
if (!normalized.sessionKey && normalized.streamUrls.size === 0) {
runCommit(commit);
return;
}
this.pending.push({ target: normalized, commit });
if (this.pending.length > MAX_PENDING_HISTORY_WRITES) {
this.pending.shift();
}
}
confirm(target: PlaybackHistoryTarget): void {
const confirmed = normalizeTarget(target);
const matched: PendingHistoryWrite[] = [];
const remaining: PendingHistoryWrite[] = [];
for (const write of this.pending) {
(matchesTarget(write.target, confirmed) ? matched : remaining).push(
write
);
}
this.pending = remaining;
matched.forEach((write) => runCommit(write.commit));
}
}
function matchesTarget(
write: NormalizedTarget,
confirmed: NormalizedTarget
): boolean {
if (write.sessionKey) {
return write.sessionKey === confirmed.sessionKey;
}
return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url));
}
function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget {
return {
sessionKey: normalizeKey(target.sessionKey),
streamUrls: new Set(
(target.streamUrls ?? [])
.map(normalizeKey)
.filter((url): url is string => url !== null)
),
};
}
function normalizeKey(key: string | null | undefined): string | null {
const trimmed = key?.trim() ?? '';
return trimmed.length > 0 ? trimmed : null;
}
function runCommit(commit: () => void): void {
try {
commit();
} catch (error) {
// History is best effort: a failed write must not break playback or
// the other writes confirmed by the same stream.
console.error('Failed to record recently viewed item:', error);
}
}
@@ -0,0 +1,9 @@
// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler.
import 'zone.js';
import 'zone.js/testing';
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless';
setupZonelessTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+29
View File
@@ -0,0 +1,29 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve",
"ignoreDeprecations": "6.0"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}