mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
671a0cb1ec
commit
e41bcb9f65
34 files changed
+250
-128
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user