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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:56:23 +02:00
1 parent 96f1707b2d
commit 671a0cb1ec
7 files changed
+111 -27

No files matched your search

@@ -55,24 +55,35 @@ describe('PlaybackHistoryGate', () => {
expect(playedInB).toHaveBeenCalledTimes(1);
});
it('falls back to the stream URL when either side has no session key', () => {
const portalWrite = jest.fn();
const radioWrite = jest.fn();
gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite);
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:p1:radio', streamUrls: ['http://radio/1'] },
radioWrite
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
failedInA
);
// A player with a session key; a player (MPV/VLC session) without.
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://radio/1'] });
gate.confirm({ streamUrls: ['http://portal/vod'] });
expect(portalWrite).toHaveBeenCalledTimes(1);
expect(radioWrite).toHaveBeenCalledTimes(1);
expect(externalWrite).toHaveBeenCalledTimes(1);
});
it('commits each confirmed write once', () => {
@@ -4,10 +4,10 @@ import { Injectable } from '@angular/core';
export interface PlaybackHistoryTarget {
/**
* The playing host's `playbackSessionKey` (source and content scoped).
* When both sides carry one, it is the only thing that is compared.
* A write deferred with one is only confirmed by that same key.
*/
readonly sessionKey?: string | null;
/** Stream URLs; the fallback when either side has no session key. */
/** Stream URLs; what a write without a session key is matched by. */
readonly streamUrls?: readonly (string | null | undefined)[];
}
@@ -39,9 +39,12 @@ const MAX_PENDING_HISTORY_WRITES = 20;
* launched. A stream that fails before that point never reaches history or
* the dashboard hero.
*
* A session key is the stronger correlation: the same stream URL can sit in
* two playlists, and playing it in one must not record a failed attempt in
* the other. Stream URLs only match when either side has no session key.
* 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.
@@ -81,7 +84,7 @@ function matchesTarget(
write: NormalizedTarget,
confirmed: NormalizedTarget
): boolean {
if (write.sessionKey && confirmed.sessionKey) {
if (write.sessionKey) {
return write.sessionKey === confirmed.sessionKey;
}
return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url));