From b0467791a46d9257b4a3cdbe66d381cc3bd3b4e9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 08:24:09 +0200 Subject: [PATCH] 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 --- docs/architecture/embedded-inline-playback.md | 3 +- .../unified-live-selection.ts | 11 +++++-- .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.spec.ts | 1 + .../unified-live-tab.fullscreen-panel.spec.ts | 33 +++++++++++++++++++ .../unified-live-tab.spec-stubs.ts | 1 + 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index dd95f226b..29d42cc03 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1150,7 +1150,8 @@ reaches history. 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. + their stream URL. The global live tab defers with its own + playlist-scoped session key, which also survives a switch to catch-up. - `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` and `playback.streamUrl` once the owned engine's reported position has advanced by 2 seconds while playing (`PlaybackProgressConfirmation`). diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index bd05802a3..2ea781c19 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -11,6 +11,7 @@ import { import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; import { UnifiedLiveTimeshift } from './unified-live-catchup'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; export interface UnifiedLiveSelection { @@ -247,9 +248,15 @@ export function createUnifiedLiveSelection(options: { } // Selecting a channel is not watching it: the row moves to the - // top of Recently Viewed once its stream has really played. + // top of Recently Viewed once its stream has really played. The + // tab's playlist-scoped session key — the one its players confirm + // with — survives a switch to catch-up and does not match the + // same URL listed in another playlist. historyGate.defer( - { streamUrls: [detail.playback.streamUrl] }, + { + sessionKey: createUnifiedLivePlaybackSessionKey(item), + streamUrls: [detail.playback.streamUrl], + }, () => void recordLivePlayback(item) ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 24ec1f36a..54a2d27f1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -58,6 +58,7 @@ activeDetail()?.playback?.title || '' " + [playbackSessionKey]="playbackSessionKey()" /> } @else { { /** What the mounted player reports once the stream has really played. */ async function playStream(streamUrl: string): Promise { TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: component.playbackSessionKey(), streamUrls: [streamUrl], }); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index be59ba6f8..837b88f35 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -22,6 +22,7 @@ import { } from '@iptvnator/portal/shared/data-access'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { StubAudioPlayerComponent, StubEpgTimelineComponent, @@ -174,6 +175,37 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { } ); + it('correlates a row with its session key, not its stream URL', async () => { + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + const gate = TestBed.inject(PlaybackHistoryGate); + + // The same URL played from another playlist does not confirm it. + gate.confirm({ + sessionKey: 'live:another-playlist:m3u-channel', + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + + // The row's own player does, even after switching to catch-up. + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: ['https://example.com/archive.m3u8'], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + it('moves a row that played to the top of Recently Viewed after another row was selected', async () => { const first = buildM3uLiveItem(); const second: UnifiedCollectionItem = { @@ -209,6 +241,7 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { await component.onChannelSelected(row('M3U Live 2')); // The first row's playback is confirmed only now, after the switch. TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: createUnifiedLivePlaybackSessionKey(first), streamUrls: [first.streamUrl], }); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index b383ad633..005fc0ef4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -101,6 +101,7 @@ export class StubAudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + readonly playbackSessionKey = input(null); } @Component({