mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(playback): record recently viewed only after the stream plays (#1732)
* feat(playback): record recently viewed only after the stream plays A channel, movie or episode used to enter Recently Viewed (and the dashboard's Continue Watching hero) the moment it was selected or its link was resolved, so streams that failed straight away cluttered the history. Writers now defer the write to a root PlaybackHistoryGate, keyed by the stream URL and/or the playback session key. The inline players confirm those keys once the owned engine's position has advanced by two seconds (seeks, stalls, pauses and a previous stream's progress do not count), the radio player does the same, and a launched MPV/VLC session confirms on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on selection. Covers M3U (live, radio, movie detail), Stalker (live, radio, VOD, series), Xtream VOD and series, and the global live collection. The M3U host's embeddedPlayback is now compared by value: the history write updates the playlist meta mid-playback, and a new but identical playback object remounted the engine and restarted the stream. E2E flows that relied on recording-on-click now play local fixtures (HLS/TS/WebM routed in place of unreachable or public streams) and wait for confirmed playback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): tighten recently viewed confirmation per review - Correlate by session key first: when both the deferred write and the confirmation carry a playbackSessionKey, only that is compared, so the same stream URL played in another playlist no longer records a failed attempt. URLs remain the fallback (portal writes, MPV/VLC sessions). The M3U radio player now receives the host's session key. - Count only playing progress: engines report `playing` (not paused, not seeking) with each time update, so short seeks of paused media no longer confirm a view. - Xtream: a write confirmed after a playlist switch still saves to its own playlist but no longer replaces the current playlist's recent list. - Global live tab: a row confirmed after another row was selected still moves to the top of an open Recently Viewed list (only a disposed tab skips the notification). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): confirm session-keyed history only by its own session A write deferred with a playback session key (M3U) is now confirmed only by that key. The app-wide MPV/VLC session confirmation carries just the URL, so opening the same stream externally from another playlist could still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is instead confirmed by the WebPlayerViewComponent that requested it, under its own session key, once the launch has opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * perf(playback): drop late Xtream confirmations off the initial path The Xtream store ships in the initial bundle, so even the small `skipListRefresh` flag cost 27 bytes there. A confirmation can only arrive after a switch to another playlist from a slow MPV/VLC launch (the inline player goes with the page), so the lazy helper now drops it instead: recording it would misfile the item or replace the other playlist's recent list. with-recent-items is back to master. This branch is now 3 bytes below master on renderer.initialBytes; the ratchet still reports master's pre-existing overage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(playback): pass the spec type-check gate from master - playback-data-access: align tsconfig.spec.json with the epg-data-access config #1705 updated (bundler resolution, global.d.ts for window.electron). - M3U recent-history spec: type the selectSignal override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep late Xtream confirmations in their own playlist history A confirmation that arrives after a switch to another playlist (a slow MPV/VLC launch) is no longer dropped: the lazy helper saves it to the captured playlist through the data source, without reloading the store's recent list, which belongs to the other playlist by then. The store and its barrel ship in the initial bundle, so the save path stays in the feature helper; renderer.initialBytes stays under the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): correlate global live-tab history by its session key The unified Favorites/Recent live tab deferred its history write by stream URL only, so the same URL played from another playlist could confirm a failed selection, and a switch to catch-up before confirmation could never match. It now defers with the tab's playlist-scoped playbackSessionKey (the key its players confirm with), and the tab's radio player receives it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep MPV/VLC rows of the live tab confirmable by URL The live tab's session key can only be confirmed by its own inline players; MPV/VLC confirm the launched URL alone. A row that goes to an external player (also later, after a double-click) now defers by URL, and only rows played inline carry the session key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): per-channel M3U history attempts, capability-based Xtream fallback - M3U: the recently-viewed dedupe key now includes the channel id, so a second row of the same URL defers its own write (its session key) and is recorded when it plays after the first row failed. - Xtream late write: key uncached content by Xtream id per supportsXtreamSqliteDataSource (the data-source factory's contract), not by a generic Electron bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
61 files changed
+2362
-147
No files matched your search
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user