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

-1
View File
@@ -6,7 +6,6 @@ export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/recordings.service';
export * from './lib/playback-position-runtime-bridge.service';
export * from './lib/playback-history-gate.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-delete-action.service';
@@ -1,156 +0,0 @@
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();
});
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();
});
});
@@ -1,117 +0,0 @@
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[] = [];
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);
}
}