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

+10 -6
View File
@@ -1145,10 +1145,11 @@ reaches history.
playlist when they defer, so navigating meanwhile cannot misfile it; an
Xtream write confirmed after a playlist switch refreshes the store's recent
list only if its playlist is still current.
- Matching: when both the write and the confirmation carry a session key,
only the session key is compared — the same URL in two playlists must not
let playback in one record a failed attempt in the other. Stream URLs are
the fallback when either side has none.
- Matching: a write deferred with a session key is confirmed only by that
same key — the same URL in two playlists must not let playback in one
(inline, or in MPV/VLC) record a failed attempt in the other. Writes
without one (portal resolvers, collection tabs) match any confirmation of
their stream URL.
- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl`
and `playback.streamUrl` once the owned engine's reported position has
advanced by 2 seconds while playing (`PlaybackProgressConfirmation`).
@@ -1161,8 +1162,11 @@ reaches history.
when given).
- MPV/VLC cannot report whether a live stream plays, so the Electron
`ExternalPlaybackService` confirms a session's `streamUrl` once it is
`opened` or `playing`; a launch that ends in `error` is not recorded. M3U
keeps recording on selection when MPV/VLC is the configured player.
`opened` or `playing`; a launch that ends in `error` is not recorded. That
confirmation carries no session key, so an "Open in MPV/VLC" recovery
launch is also confirmed by the `WebPlayerViewComponent` that requested
it, under its own session key, once the launch has opened. M3U keeps
recording on selection when MPV/VLC is the configured player.
- A confirmation commits every write matching it, once. Unconfirmed writes
are bounded (oldest dropped) and simply never commit. The global live tab
moves a confirmed row to the top of an open Recently Viewed list even if
@@ -215,7 +215,10 @@ describe('VideoPlayerComponent — recently viewed history', () => {
name: 'Second TV',
});
gate.confirm({ streamUrls: ['http://localhost/second.m3u8'] });
gate.confirm({
sessionKey: component.playbackSessionKey(),
streamUrls: ['http://localhost/second.m3u8'],
});
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
@@ -226,7 +229,7 @@ describe('VideoPlayerComponent — recently viewed history', () => {
);
});
it('records a radio station once the audio player confirms its URL', () => {
it('records a radio station once the audio player confirms it', () => {
const radio = { ...sampleChannel, radio: 'true' };
select(radio);
@@ -234,7 +237,16 @@ describe('VideoPlayerComponent — recently viewed history', () => {
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
// The same URL opened elsewhere (MPV/VLC, another playlist) does not.
gate.confirm({ streamUrls: [radio.url] });
expect(
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
gate.confirm({
sessionKey: component.playbackSessionKey(),
streamUrls: [radio.url],
});
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
@@ -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));
@@ -83,6 +83,38 @@ describe('ExternalPlaybackRecoveryCoordinator', () => {
coordinator.destroy();
});
it.each([
['an opened launch', session({ status: 'opened' }), 1],
['a launch that failed', session({ status: 'error' }), 0],
['a launch of another player', session({ player: 'vlc' }), 0],
])(
'reports %s to the view',
async (_label, launched: ExternalPlayerSession, calls: number) => {
const activeSession = signal<ExternalPlayerSession | null>(null);
const onLaunched = jest.fn();
const coordinator = new ExternalPlaybackRecoveryCoordinator(
{
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
},
onLaunched
);
coordinator.syncSession('content-a');
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
trackLaunch(Promise.resolve(launched));
return true;
});
await Promise.resolve();
await Promise.resolve();
expect(onLaunched).toHaveBeenCalledTimes(calls);
coordinator.destroy();
}
);
it('closes a closable error before retrying another external target', async () => {
const previous = session({
id: 'uncertain-process',
@@ -21,8 +21,16 @@ export class ExternalPlaybackRecoveryCoordinator {
readonly states = this.recovery.states;
readonly pending = this.recovery.pending;
/**
* @param onLaunched runs once a launch this view requested has opened in
* MPV/VLC — still owned by the current intent — e.g. to record it as
* recently viewed under the view's own playback session.
*/
constructor(
private readonly externalPlayback: PortalExternalPlayback | null
private readonly externalPlayback: PortalExternalPlayback | null,
private readonly onLaunched: (
session: ExternalPlayerSession
) => void = () => undefined
) {}
observe(session: ExternalPlayerSession | null): void {
@@ -93,7 +101,13 @@ export class ExternalPlaybackRecoveryCoordinator {
void launch.then(
(session) => {
if (session) {
this.recovery.confirm(intent, session);
if (
this.recovery.confirm(intent, session) &&
(session.status === 'opened' ||
session.status === 'playing')
) {
this.onLaunched(session);
}
} else {
this.recovery.fail(intent);
}
@@ -122,8 +122,16 @@ export class WebPlayerViewComponent implements OnDestroy {
optional: true,
});
private readonly recoverySession = new PlaybackRecoverySession();
private readonly historyGate = inject(PlaybackHistoryGate);
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
this.externalPlayback
this.externalPlayback,
// "Open in MPV/VLC" after an inline failure is still this session's
// view; the app-wide session confirmation carries only the URL.
(session) =>
this.historyGate.confirm({
sessionKey: this.playbackSessionKey(),
streamUrls: [session.streamUrl],
})
);
private readonly applicationHandoff =
new WebPlayerApplicationHandoffCoordinator(
@@ -221,7 +229,7 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playbackApplicationToken = this.applicationState.token;
/** Commits deferred "recently viewed" writes once this stream plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: inject(PlaybackHistoryGate),
gate: this.historyGate,
target: () => ({
sessionKey: this.playbackSessionKey(),
streamUrls: [this.streamUrl(), this.playback()?.streamUrl],