From 5cc10ad7e20ade786259e448ef203f4ddb120598 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 22:44:43 +0200 Subject: [PATCH] feat(playback): identify content recovery sessions --- .changes/playback-stable-session-ownership.md | 9 + docs/architecture/embedded-inline-playback.md | 31 ++ .../video-player/video-player.component.html | 9 +- .../video-player.component.spec.ts | 62 ++- .../video-player/video-player.component.ts | 20 +- .../unified-favorites-data.service.spec.ts | 4 + .../unified-recent-data.service.spec.ts | 1 + .../unified-live-playback-session-key.spec.ts | 47 ++ .../unified-live-playback-session-key.ts | 23 + .../unified-live-tab.component.html | 9 +- .../unified-live-tab.component.spec.ts | 45 +- .../unified-live-tab.component.ts | 14 +- .../stalker-catalog-detail.component.html | 1 + .../stalker-catalog-detail.component.spec.ts | 147 +++++- .../stalker-catalog-detail.component.ts | 40 +- ...talker-collection-detail.component.spec.ts | 166 +++++- .../stalker-collection-detail.component.ts | 49 ++ .../stalker-inline-detail.component.html | 1 + .../stalker-inline-detail.component.spec.ts | 6 + .../stalker-inline-detail.component.ts | 1 + .../stalker-live-stream-layout.component.html | 13 +- ...alker-live-stream-layout.component.spec.ts | 91 +++- .../stalker-live-stream-layout.component.ts | 106 +++- ...stream-layout.playback-session-key.spec.ts | 294 +++++++++++ .../stalker-search.component.html | 1 + .../stalker-search.component.spec.ts | 273 ++++++++++ .../stalker-search.component.ts | 47 +- ...alker-episode-playback-session-key.spec.ts | 120 +++++ .../stalker-episode-playback-session-key.ts | 184 +++++++ .../stalker-series-view.component.html | 1 + .../stalker-series-view.component.spec.ts | 14 +- .../stalker-series-view.component.ts | 404 ++++++--------- ...r-series-view.playback-session-key.spec.ts | 479 ++++++++++++++++++ .../stalker-vod-playback-controller.spec.ts | 28 + .../lib/stalker-vod-playback-controller.ts | 25 +- .../live-stream-layout.component.html | 1 + .../live-stream-layout.component.spec.ts | 167 ++++-- .../live-stream-layout.component.ts | 22 + .../serial-details-playback.service.ts | 17 +- .../serial-details.component.html | 1 + .../serial-details.component.spec.ts | 82 ++- .../serial-details.component.ts | 8 + .../serial-playback-session-key.ts | 28 + .../vod-details-route.actions.spec.ts | 32 ++ .../vod-details-route.component.html | 1 + .../vod-details-route.component.ts | 8 + .../portal-inline-player-sources.spec.ts | 12 +- .../portal-inline-player-up-next.spec.ts | 15 +- .../portal-inline-player.component.html | 1 + .../portal-inline-player.component.spec.ts | 78 ++- .../portal-inline-player.component.ts | 13 +- .../vod-details/vod-details.component.html | 1 + .../vod-details/vod-details.component.spec.ts | 23 + .../lib/vod-details/vod-details.component.ts | 1 + ...yer-view.component.shared-controls.spec.ts | 2 + .../web-player-view.component.spec.ts | 2 + .../web-player-view.component.ts | 10 +- .../global-favorites.service.spec.ts | 65 ++- 58 files changed, 2895 insertions(+), 460 deletions(-) create mode 100644 .changes/playback-stable-session-ownership.md create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts diff --git a/.changes/playback-stable-session-ownership.md b/.changes/playback-stable-session-ownership.md new file mode 100644 index 000000000..cc914c765 --- /dev/null +++ b/.changes/playback-stable-session-ownership.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playback +issues: [1159] +--- + +Playback recovery no longer crosses into a different selected channel, movie, +or exact Stalker episode. Switching content while a stream resolves no longer +restores stale playback or shows an error from the previous selection. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 426664fba..cfa3ad393 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -38,6 +38,37 @@ Collection/search VOD surfaces that expose embedded playback must host `PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to create embedded UI. +## Logical Playback Identity + +Every inline playback host owns a required, URL-independent +`playbackSessionKey`. Live hosts derive it from the playlist/source and the +current channel identity; an M3U session uses `Channel.id` rather than a mutable +stream or catch-up URL. VOD and series hosts use the route or catalog +content identity, with series episode coordinates. Stalker episode identity +also includes the series mode, normalized parent, exact season key, and the +original provider command or episode ID; synthesized episode hashes are not +identity. Selection and pending-resolution guards use that exact provider +episode identity, so colliding tracking hashes cannot select a sibling episode. +Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass +the key unchanged to `WebPlayerViewComponent`. + +Hosts invalidate both committed playback and pending resolution when the +canonical owner changes (playlist/source, content, and mode where applicable). +Refreshing data for the same canonical owner preserves the mounted player. +Collection UIDs remain a separate persistence concern; legacy M3U collection +UIDs continue to use stream URLs so saved favorites ordering remains compatible. + +Temporary portal URLs, catch-up URLs, headers, DRM data, and alternative source +payloads are transport details and must not change this logical identity. A +content or episode change must produce a different key. The serialized key is +created with `createPlaybackSessionKey()` from `@iptvnator/playback/util` so +delimiter-bearing provider IDs remain unambiguous. + +Inline hosts capture this identity before asynchronous playback resolution. A +completion may mount only while the same owner is current; stale completions +and embedded starts without a complete canonical identity are ignored without +replacing an already committed session. + ## Embedded MPV Harness The repository now contains a first-pass native embedded MPV harness for Electron: diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index f4e33afdb..3e50589e0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -79,6 +79,7 @@ /> } @else { ({ @@ -113,6 +115,7 @@ class StubAudioPlayerComponent { template: '', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); @@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => { } as typeof window.electron; syncStoreState(null); + playlistId.set('playlist-1'); localStorage.removeItem('m3u-sidebar-width'); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); player.set(VideoPlayer.VideoJs); @@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); expect(headerContext.action()).toBeNull(); }); @@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => { }, } as Channel); + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://archive.example.com/live.m3u8?utc=1', + title: 'Sample TV', + isLive: true, + headers: { + 'user-agent': 'IPTVnator Test', + Referer: 'https://referrer.example.com', + Origin: 'https://origin.example.com', + }, + }; component.handleExternalFallbackRequest({ player: 'mpv', - playback: { - streamUrl: 'https://archive.example.com/live.m3u8?utc=1', - title: 'Archive Sample', - }, + playback, diagnostic: { code: 'unsupported-codec', source: 'hls', @@ -583,13 +596,13 @@ describe('VideoPlayerComponent', () => { expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith( 'OPEN_MPV_PLAYER', - expect.objectContaining({ + { url: 'https://archive.example.com/live.m3u8?utc=1', title: 'Sample TV', 'user-agent': 'IPTVnator Test', referer: 'https://referrer.example.com', origin: 'https://origin.example.com', - }) + } ); }); @@ -640,12 +653,36 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled(); }); + it('owns a collision-safe live session key that ignores resolved URL changes', () => { + playlistId.set('playlist|one'); + syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel); + activePlaybackUrl.set('https://archive.example/first.m3u8'); + fixture.detectChanges(); + + const playerView = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist|one', + contentId: 'channel|one', + }); + expect(playerView.playbackSessionKey()).toBe(expected); + + activePlaybackUrl.set('https://archive.example/second.m3u8'); + fixture.detectChanges(); + expect(playerView.playbackSessionKey()).toBe(expected); + + syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel); + fixture.detectChanges(); + expect(playerView.playbackSessionKey()).not.toBe(expected); + }); + it('routes catch-up playback that resolves to a DASH URL inline as well', () => { syncStoreState(sampleChannel); activePlaybackUrl.set('http://localhost/archive/replay.mpd'); @@ -657,8 +694,7 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); }); @@ -675,9 +711,7 @@ describe('VideoPlayerComponent', () => { // The external-player guard declines DASH channels, so the inline // player must stay — otherwise the session has no player at all. expect( - fixture.debugElement.query( - By.directive(StubWebPlayerViewComponent) - ) + fixture.debugElement.query(By.directive(StubWebPlayerViewComponent)) ).not.toBeNull(); }); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 1a39c26fc..72714b01c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -90,6 +90,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, @@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly activePlaybackUrl = this.store.selectSignal( selectActivePlaybackUrl ); - readonly activeEpgProgram = this.store.selectSignal( - selectActiveEpgProgram - ); + readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram); readonly activeEpgProgramOrNull = computed( () => this.activeEpgProgram() ?? null ); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; + readonly playbackSessionKey = computed(() => { + const sourceId = this.activePlaylistId(); + const contentId = this.activeChannel()?.id; + return sourceId && contentId !== undefined + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; + }); readonly channels = this.store.selectSignal(selectChannels); readonly channelsLoading = this.store.selectSignal(selectChannelsLoading); readonly archivePlaybackAvailable = computed(() => @@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly epgChannelLogo = toSignal( toObservable(this.activeChannel).pipe( switchMap((channel) => { - const key = channel - ? resolveChannelEpgLookupKey(channel) - : ''; + const key = channel ? resolveChannelEpgLookupKey(channel) : ''; if (!key) { return of(''); } @@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } const currentEpgProgram = epgProgram as - | EpgProgram - | null - | undefined; + EpgProgram | null | undefined; const currentIndex = channels.findIndex( (channel) => channel.url === activeChannel.url ); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index ede3bcbd5..da7afe700 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => { 'https://example.com/2.m3u8', 'https://example.com/1.m3u8', ]); + expect(items.map((item) => item.uid)).toEqual([ + 'm3u::m3u-1::https://example.com/2.m3u8', + 'm3u::m3u-1::https://example.com/1.m3u8', + ]); expect(items[1].channelId).toBe('channel-1'); expect(items[0].radio).toBe('true'); expect(items[0].m3uChannel).toBe(m3uChannels[1]); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 6cf29a142..98573a237 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => { expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ + uid: 'm3u::m3u-1::https://example.com/2.m3u8', sourceType: 'm3u', streamUrl: 'https://example.com/2.m3u8', channelId: 'channel-2', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts new file mode 100644 index 000000000..6306dfb29 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts @@ -0,0 +1,47 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; + +describe('createUnifiedLivePlaybackSessionKey', () => { + it('keeps an M3U key across transport changes and separates channels', () => { + const first = m3uItem({ + uid: 'm3u::pl-1::https://old.example/live.m3u8', + streamUrl: 'https://old.example/live.m3u8', + channelId: 'provider-channel-7', + }); + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'pl-1', + contentId: 'provider-channel-7', + }); + + expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected); + expect( + createUnifiedLivePlaybackSessionKey({ + ...first, + uid: 'm3u::pl-1::https://new.example/timeshift.m3u8', + streamUrl: 'https://new.example/timeshift.m3u8', + }) + ).toBe(expected); + expect( + createUnifiedLivePlaybackSessionKey({ + ...first, + channelId: 'provider-channel-8', + }) + ).not.toBe(expected); + }); +}); + +function m3uItem( + overrides: Partial +): UnifiedCollectionItem { + return { + uid: 'm3u::pl-1::channel', + name: 'Channel', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'pl-1', + playlistName: 'Playlist', + ...overrides, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts new file mode 100644 index 000000000..22f93442e --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts @@ -0,0 +1,23 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; + +export function createUnifiedLivePlaybackSessionKey( + item: UnifiedCollectionItem | null +): string { + const sourceId = item?.playlistId.trim() ?? ''; + const contentId = item ? getUnifiedLiveContentId(item) : ''; + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; +} + +function getUnifiedLiveContentId(item: UnifiedCollectionItem): string { + switch (item.sourceType) { + case 'm3u': + return item.channelId?.trim() || item.m3uChannel?.id?.trim() || ''; + case 'xtream': + return item.xtreamId == null ? '' : String(item.xtreamId); + case 'stalker': + return item.stalkerId == null ? '' : String(item.stalkerId).trim(); + } +} 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 df9ce8167..94e3de217 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 @@ -59,6 +59,7 @@ /> } @else { (); readonly streamUrl = input.required(); readonly title = input(''); readonly playback = input(null); @@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => { expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8'); expect(webPlayer.title()).toBe('Xtream Live'); + const sourceId = item.playlistId; + const contentId = String(item.xtreamId); + const key = sessionKey({ kind: 'live', sourceId, contentId }); + expect(webPlayer.playbackSessionKey()).toBe(key); + component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' }); + expect(component.playbackSessionKey()).not.toBe(key); + component.activeItem.set(item); const playback = webPlayer.playback(); expect(playback).toEqual( expect.objectContaining({ @@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => { throw new Error('Expected wrapper playback to be set'); } expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs); - expect( - fixture.nativeElement.querySelector('app-vjs-player') - ).toBeNull(); - expect( - fixture.nativeElement.querySelector('app-html-video-player') - ).toBeNull(); - expect( - fixture.nativeElement.querySelector('app-art-player') - ).toBeNull(); + const host = fixture.nativeElement; + expect(host.querySelector('app-vjs-player')).toBeNull(); + expect(host.querySelector('app-html-video-player')).toBeNull(); + expect(host.querySelector('app-art-player')).toBeNull(); webPlayer.externalFallbackRequested.emit({ player: 'mpv', @@ -622,12 +623,10 @@ describe('UnifiedLiveTabComponent', () => { }, }); - expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith( - expect.objectContaining({ - streamUrl: 'https://example.com/xtream.m3u8', - }), - 'mpv' - ); + const [forwardedPlayback, forwardedPlayer] = + portalPlayer.openExternalPlayback.mock.calls[0]; + expect(forwardedPlayback).toBe(playback); + expect(forwardedPlayer).toBe('mpv'); }); it('does not wait for M3U program lookup before opening playback', async () => { @@ -749,9 +748,9 @@ describe('UnifiedLiveTabComponent', () => { // auth-gated streams) before the audio element gets the URL, and // clears it again on close. const setUserAgent = jest.fn().mockResolvedValue(true); - ( - window.electron as unknown as Record - )['setUserAgent'] = setUserAgent; + (window.electron as unknown as Record)[ + 'setUserAgent' + ] = setUserAgent; const item = { ...buildLiveItem('stalker'), name: 'Jazz Radio', @@ -1177,6 +1176,7 @@ describe('UnifiedLiveTabComponent', () => { 'https://example.com/timeshift.m3u8' ); const item = await selectXtreamArchiveChannel(); + const key = component.playbackSessionKey(); component.onTimelineProgramActivated(timeshiftEvent()); await fixture.whenStable(); @@ -1192,6 +1192,7 @@ describe('UnifiedLiveTabComponent', () => { 'https://example.com/timeshift.m3u8' ); expect(component.inlinePlayback()?.isLive).toBe(false); + expect(component.playbackSessionKey()).toBe(key); expect(component.activeTimeshiftProgram()?.title).toBe( 'Xtream Show' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 677c90f73..e95223f81 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -62,6 +62,7 @@ import { import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; @Component({ selector: 'app-unified-live-tab', @@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent { * portal archive fields (tvArchive/tvArchiveDuration) and to * supply credentials for Xtream catch-up URL resolution. */ readonly activeItem = signal(null); + readonly playbackSessionKey = computed(() => + createUnifiedLivePlaybackSessionKey(this.activeItem()) + ); readonly isSelecting = signal(false); readonly epgMap = signal>(new Map()); readonly progressTick = signal(0); @@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent { return getLiveEpgPanelSummary(this.activeDetail()); }); readonly liveEpgPanelSummaryLabelKey = computed(() => - this.activeTimeshift() - ? 'EPG.ARCHIVE_PLAYBACK' - : 'EPG.CURRENT_PROGRAM' + this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' ); readonly channelsForList = computed((): UnifiedFavoriteChannel[] => @@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent { program.startTimestamp, program.start ); - const stopEpoch = toEpochSeconds( - program.stopTimestamp, - program.stop - ); + const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); if (startEpoch == null || stopEpoch == null) { this.snackBar.open( this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), @@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent { }; }); } - } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index 74eeca1f1..39c85e94a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -8,6 +8,7 @@ } @else if (vodDetailsItem()) { () { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} @Component({ selector: 'app-vod-details', template: '' }) class StubVodDetailsComponent { + readonly playbackSessionKey = input.required(); readonly item = input.required(); readonly providerOnly = input(false); readonly isFavorite = input(false); @@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent { describe('StalkerCatalogDetailComponent provider presentation', () => { let fixture: ComponentFixture; + const resolveVodPlayback = jest.fn(); + const portalPlayer = { + isEmbeddedPlayer: jest.fn(() => true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }; const contentType = signal<'vod' | 'series'>('vod'); + const catalogPlaylist = signal({ id: 'stalker-1' }); + const snackBar = { open: jest.fn() }; const selectedItem = signal({ id: '42', cmd: '/media/42', @@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { cmd: '/media/42', info: { name: 'Portal movie' }, }); + resolveVodPlayback.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + catalogPlaylist.set({ id: 'stalker-1' }); + snackBar.open.mockReset(); await TestBed.configureTestingModule({ imports: [StalkerCatalogDetailComponent], @@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { useValue: { contentType, selectedItem, - playlist: signal({ id: 'stalker-1' }), + playlist: catalogPlaylist, clearSelectedItem: jest.fn(), + resolveVodPlayback, }, }, { @@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { }, { provide: PORTAL_PLAYER, - useValue: { isEmbeddedPlayer: jest.fn() }, + useValue: portalPlayer, }, { provide: PORTAL_EXTERNAL_PLAYBACK, @@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { }, { provide: DownloadsService, useValue: {} }, { provide: Router, useValue: { navigateByUrl: jest.fn() } }, - { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, { provide: TranslateService, useValue: { @@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { ).componentInstance as StubVodDetailsComponent; expect(fixture.componentInstance.providerOnly()).toBe(true); expect(child.providerOnly()).toBe(true); + expect(child.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '42', + }) + ); }); it('keeps provider-only presentation disabled for a regular VOD open', async () => { @@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { ).componentInstance as StubVodDetailsComponent; expect(fixture.componentInstance.providerOnly()).toBe(false); expect(child.providerOnly()).toBe(false); + expect(child.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '99', + }) + ); }); it.each([ @@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { expect(child.providerOnly()).toBe(true); } ); + + it('does not mount a VOD resolution after the catalog owner changes', async () => { + let resolve!: (value: { streamUrl: string }) => void; + resolveVodPlayback.mockReturnValueOnce( + new Promise((resolvePromise) => { + resolve = resolvePromise; + }) + ); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay({ + type: 'stalker', + cmd: '/media/42', + data: selectedItem(), + } as never); + + selectedItem.set({ + id: '99', + cmd: '/media/99', + info: { name: 'Replacement movie' }, + }); + fixture.detectChanges(); + resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + }); + + it('clears committed VOD playback when the playlist changes with the same provider id', async () => { + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-2', + contentId: '42', + }) + ); + }); + + it('does not mount a pending VOD resolution after only the playlist owner changes', async () => { + const pending = deferred<{ streamUrl: string }>(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + }); + + it('suppresses a pending VOD error after the playlist owner changes', async () => { + const pending = deferred<{ streamUrl: string }>(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + pending.reject(new Error('stale playlist failure')); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('preserves committed playback across a same-owner item refresh', async () => { + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + + selectedItem.set({ + ...(selectedItem() as Record), + screenshot_uri: 'refreshed.jpg', + }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 80c0f77a3..804e3d111 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -23,6 +23,7 @@ import { createRefreshTrigger, isStalkerSeriesFlag, isSelectedStalkerVodFavorite, + normalizeStalkerEntityId, StalkerSelectedVodItem, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; @@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { startStalkerVodDownload } from './stalker-vod-download'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-stalker-catalog-detail', @@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy { private readonly downloadsService = inject(DownloadsService); private readonly logger = createLogger('StalkerCatalogDetail'); private readonly favoritesRefresh = createRefreshTrigger(); + private playbackRequestId = 0; + private currentPlaybackOwnerKey = ''; readonly contentType = this.catalog.contentType; readonly selectedItem = computed( @@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy { ); readonly inlinePlayback = signal(null); readonly providerOnly = input(false); + readonly playbackSessionKey = computed(() => { + const sourceId = this.catalog.playlist()?.id; + const contentId = normalizeStalkerEntityId(this.selectedItem()?.id); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); + private readonly playbackOwnerKey = computed(() => + JSON.stringify([this.playbackSessionKey(), this.contentType()]) + ); private readonly selectedVodPosition = signal( null ); @@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy { }); effect(() => { - const selectedItemId = this.selectedItem()?.id; - void selectedItemId; + const ownerKey = this.playbackOwnerKey(); + if (ownerKey === this.currentPlaybackOwnerKey) return; + this.currentPlaybackOwnerKey = ownerKey; this.closeInlinePlayer(); }); @@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } closeInlinePlayer(): void { + this.playbackRequestId += 1; this.inlinePlayback.set(null); this.positionWriter.reset(); } @@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy { thumbnail?: string, startTime?: number ): Promise { + const requestId = ++this.playbackRequestId; + const sessionKey = this.playbackSessionKey(); + const ownerKey = this.playbackOwnerKey(); + const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer(); + if (usesEmbeddedPlayer && !sessionKey) return; + try { const playback = await this.catalog.resolveVodPlayback( cmd, @@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy { thumbnail, startTime ); + if ( + requestId !== this.playbackRequestId || + this.playbackOwnerKey() !== ownerKey + ) { + return; + } this.positionWriter.reset(); - if (this.portalPlayer.isEmbeddedPlayer()) { + if (usesEmbeddedPlayer) { this.inlinePlayback.set(playback); return; } @@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy { this.closeInlinePlayer(); void this.portalPlayer.openResolvedPlayback(playback, true); } catch (error) { + if ( + requestId !== this.playbackRequestId || + this.playbackOwnerKey() !== ownerKey + ) { + return; + } this.logger.error('Failed to start inline VOD playback', error); const errorMessage = error instanceof Error && error.message === 'nothing_to_play' diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index ec7278c47..8a450154d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -1,5 +1,6 @@ import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from '@iptvnator/ui/components'; @@ -20,6 +21,7 @@ import { import { of } from 'rxjs'; import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-content-hero', @@ -36,6 +38,7 @@ class StubContentHeroComponent { template: '', }) class StubStalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); @@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => { getAllPlaybackPositions: jest.Mock; clearPlaybackPosition: jest.Mock; }; + let snackBar: { open: jest.Mock }; + let playlistsService: { getPlaylistById: jest.Mock }; const playlist = { _id: 'stalker-1', @@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => { getAllPlaybackPositions: jest.fn(), clearPlaybackPosition: jest.fn(), }; + snackBar = { open: jest.fn() }; + playlistsService = { + getPlaylistById: jest.fn((playlistId: string) => + of({ ...playlist, _id: playlistId }) + ), + }; await TestBed.configureTestingModule({ imports: [StalkerCollectionDetailComponent], @@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => { }, { provide: MatSnackBar, - useValue: { - open: jest.fn(), - }, + useValue: snackBar, }, { provide: TranslateService, @@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => { { provide: PlaylistsService, useValue: { - getPlaylistById: jest.fn(() => of(playlist)), + getPlaylistById: playlistsService.getPlaylistById, getPortalFavorites: jest.fn(() => of([])), }, }, @@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => { ).toEqual([1, 2]); // The snapshot renders immediately, but a background portal // re-fetch must be triggered so new episodes can appear. - expect( - stalkerStore.refreshEmbeddedSeriesSelection - ).toHaveBeenCalled(); + expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled(); }); it('shows newly released episodes when the background snapshot refresh patches the selection', async () => { @@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => { expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled(); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect(fixture.componentInstance.inlinePlayback()).toEqual(playback); + const detail = fixture.debugElement.query( + By.directive(StubStalkerInlineDetailComponent) + ).componentInstance as StubStalkerInlineDetailComponent; + expect(detail.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '1701', + }) + ); + }); + + it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => { + const pendingA = deferred(); + const pendingB = deferred(); + stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) => + cmd.includes('1701') ? pendingA.promise : pendingB.promise + ); + const itemA = buildCollectionItem({ + stalkerId: '1701', + stalkerItem: buildVodSource('1701', 'Movie A'), + }); + const itemB = buildCollectionItem({ + stalkerId: '1702', + stalkerItem: buildVodSource('1702', 'Movie B'), + }); + + fixture.componentRef.setInput('item', itemA); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', itemB); + await settleDetail(fixture); + pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '1702', + }) + ); + + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId) + ); + pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toEqual({ + streamUrl: 'https://streams.test/b.mp4', + }); + }); + + it('suppresses a pending error after the collection playlist owner changes', async () => { + const pending = deferred(); + stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise); + const source = buildVodSource('1701', 'Movie A'); + const itemA = buildCollectionItem({ + playlistId: 'stalker-1', + stalkerId: '1701', + stalkerItem: source, + }); + const itemB = buildCollectionItem({ + playlistId: 'stalker-2', + stalkerId: '1701', + stalkerItem: { ...source }, + }); + + fixture.componentRef.setInput('item', itemA); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', itemB); + await settleDetail(fixture); + pending.reject(new Error('stale A failure')); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-2', + contentId: '1701', + }) + ); + }); + + it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => { + const pending = deferred(); + stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise); + const item = buildCollectionItem({ + stalkerId: '1701', + stalkerItem: buildVodSource('1701', 'Movie A'), + }); + + fixture.componentRef.setInput('item', item); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(item.stalkerItem as never, item.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', { + ...item, + stalkerItem: { ...(item.stalkerItem as object) }, + }); + await settleDetail(fixture); + pending.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toEqual({ + streamUrl: 'https://streams.test/a.mp4', + }); + expect(snackBar.open).not.toHaveBeenCalled(); }); it('does not load VOD playback position when the playlist id is missing', async () => { @@ -444,6 +576,26 @@ function buildCollectionItem( }; } +function buildVodSource(id: string, title: string) { + return { + id, + title, + category_id: 'vod', + cmd: `/media/file_${id}.mpg`, + info: { name: title }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + async function settleDetail( fixture: ComponentFixture ): Promise { diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 370867993..a8e575952 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -48,6 +48,7 @@ import { import { firstValueFrom } from 'rxjs'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; interface StalkerCollectionStateSnapshot { currentPlaylist: Playlist | undefined; @@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode { needsSeriesFetch: boolean; } +interface StalkerCollectionPlaybackOwner { + readonly sourceId: string; + readonly contentId: string; + readonly sessionKey: string; +} + @Component({ selector: 'app-stalker-collection-detail', imports: [PortalDetailShellComponent, StalkerInlineDetailComponent], template: ` @if (inlineDetail().categoryId) { (null); readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); + private readonly playbackOwner = computed(() => + captureStalkerCollectionPlaybackOwner(this.item()) + ); + readonly playbackSessionKey = computed( + () => this.playbackOwner()?.sessionKey ?? '' + ); private readonly selectedVodPosition = signal( null ); @@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent { ); private initRequestId = 0; + private currentPlaybackOwnerKey = ''; private readonly vodPlayback = new StalkerVodPlaybackController({ inlinePlayback: this.inlinePlayback, selectedVodPosition: this.selectedVodPosition, @@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent { translateService: this.translateService, logger: this.logger, playbackErrorLogMessage: 'Failed to start collection VOD playback', + playbackOwnerKey: () => this.playbackSessionKey(), }); constructor() { @@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent { effect(() => { const item = this.item(); + const playbackOwnerKey = this.playbackSessionKey(); untracked(() => { + if (playbackOwnerKey !== this.currentPlaybackOwnerKey) { + this.currentPlaybackOwnerKey = playbackOwnerKey; + this.closeInlinePlayer(); + } void this.prepareDetail(item); }); }); @@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent { await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } + +function captureStalkerCollectionPlaybackOwner( + item: UnifiedCollectionItem | null +): StalkerCollectionPlaybackOwner | null { + if (!item) return null; + + const sourceId = item.playlistId.trim(); + const providerItem = item.stalkerItem as + { id?: unknown; stream_id?: unknown } | undefined; + const uidParts = item.uid.split('::'); + const contentId = normalizeStalkerEntityId( + providerItem?.id ?? + providerItem?.stream_id ?? + item.stalkerId ?? + uidParts[uidParts.length - 1] + ); + if (!sourceId || !contentId) return null; + + return Object.freeze({ + sourceId, + contentId, + sessionKey: createPlaybackSessionKey({ + kind: 'vod', + sourceId, + contentId, + }), + }); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index b97e8e102..00997ac87 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -15,6 +15,7 @@ } @else if (vodDetailsItem(); as item) { (); readonly item = input.required(); readonly providerOnly = input(false); readonly isFavorite = input(false); @@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => { .compileComponents(); fixture = TestBed.createComponent(StalkerInlineDetailComponent); fixture.componentRef.setInput('providerOnly', true); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'collection-owned-vod-key' + ); }); afterEach(() => fixture.destroy()); @@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => { By.directive(StubVodDetailsComponent) ).componentInstance as StubVodDetailsComponent; expect(child.providerOnly()).toBe(true); + expect(child.playbackSessionKey()).toBe('collection-owned-vod-key'); }); it.each([ diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index 37cfaf1ef..10fe38211 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie ], }) export class StalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 7e53dea5b..b8bc99d2d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -32,7 +32,9 @@ mat-icon-button type="button" [disabled]="isFullListLoading()" - [attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate" + [attr.aria-label]=" + 'PORTALS.REFRESH_CHANNEL_LIST' | translate + " [matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate" (click)="refreshChannels()" > @@ -83,7 +85,9 @@
} @else { @@ -120,7 +124,9 @@ (clicked)="playChannel(item)" (activated)="playChannel(item, true)" (favoriteToggled)="toggleFavorite(item)" - (contextMenuRequested)="onChannelContextMenu(item, $event)" + (contextMenuRequested)=" + onChannelContextMenu(item, $event) + " /> } } @@ -180,6 +186,7 @@ /> } @else { (); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); } @Component({ @@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { loaded: number; total: number; } | null>(null); - const itvFullChannelList = signal< - ReturnType - >([]); + const itvFullChannelList = signal>( + [] + ); const itvSelectedCategoryFromCache = signal(false); const isPaginatedContentLoading = signal(false); @@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { const portalPlayer = { isEmbeddedPlayer: jest.fn(() => true), openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), }; const settingsStore = { openStreamOnDoubleClick: signal(false), @@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { // The store mock is module-scoped: reset so a failed test can't leak // 'list' into siblings. settingsStore.resolvedEpgViewMode.set('timeline'); + playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' }); window.electron = { platform: 'darwin', setUserAgent: jest.fn().mockResolvedValue(true), @@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { portalPlayer.isEmbeddedPlayer.mockReset(); portalPlayer.isEmbeddedPlayer.mockReturnValue(true); portalPlayer.openResolvedPlayback.mockClear(); + portalPlayer.openExternalPlayback.mockClear(); fetchChannelEpg.mockReset(); fetchChannelEpg.mockResolvedValue([]); ensureBulkItvEpg.mockReset(); @@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { useValue: { supportsEpgMapping: false, getEpgMapping: jest.fn().mockResolvedValue(null), - getEpgMappingsBatch: jest - .fn() - .mockResolvedValue(null), + getEpgMappingsBatch: jest.fn().mockResolvedValue(null), }, }, ], @@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); const channelRows = fixture.debugElement.queryAll( By.directive(StubChannelListItemComponent) ); @@ -526,6 +533,44 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBe(true); }); + it('forwards the exact resolved live playback to external fallback', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/stalker-fallback.m3u8', + title: 'Alpha TV', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 10001, + contentType: 'live', + }, + }; + const request: PlaybackFallbackRequest = { + player: 'vlc', + playback, + diagnostic: { + code: 'network-error', + player: 'html5', + source: 'hls', + container: '', + mimeType: '', + videoCodecs: [], + audioCodecs: [], + externalFallbackRecommended: true, + }, + }; + + component.handleExternalFallbackRequest(request); + + expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith( + playback, + 'vlc' + ); + expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe( + playback + ); + }); + it('renders radio channel rows with compact no-EPG density', () => { stalkerStore.selectedContentType.set('radio'); @@ -1115,7 +1160,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(ensureBulkItvEpg).not.toHaveBeenCalled(); expect(fetchChannelEpg).not.toHaveBeenCalled(); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); expect( fixture.nativeElement.querySelector('app-audio-player') ).not.toBeNull(); @@ -1185,14 +1232,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); // Once: only the apply call gets the pending promise — the destroy // clear below also calls the bridge and must not steal the resolver. - ( - window.electron?.setUserAgent as jest.Mock - ).mockImplementationOnce(() => { - signalApplyIssued(); - return new Promise((resolve) => { - resolveApply = () => resolve(true); - }); - }); + (window.electron?.setUserAgent as jest.Mock).mockImplementationOnce( + () => { + signalApplyIssued(); + return new Promise((resolve) => { + resolveApply = () => resolve(true); + }); + } + ); resolveRadioPlayback.mockResolvedValue({ streamUrl: 'http://portal.example/radio_2.mpg', title: 'Portal FM', diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e67469273..3b795996c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -76,12 +76,24 @@ import { normalizeStalkerEntityId, } from '@iptvnator/portal/stalker/data-access'; import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; type StalkerPlayableChannel = StalkerPortalItem & { cmd?: string; has_files?: unknown; }; +interface StalkerActiveLivePlaybackIdentity { + readonly sourceId: string; + readonly contentId: string; +} + +interface StalkerPlaybackResolutionOwner { + readonly sourceId: string; + readonly contentType: string; + readonly channelId: string; +} + /** Channels rendered per "page" when the full list is served from the cache. */ const FULL_LIST_RENDER_CHUNK = 100; @@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); + private readonly activePlaybackIdentity = + signal(null); + readonly playbackSessionKey = computed(() => { + const identity = this.activePlaybackIdentity(); + return identity + ? createPlaybackSessionKey({ kind: 'live', ...identity }) + : ''; + }); readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? ''); readonly activePlaybackTitle = computed( () => @@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { /** Stream URL of the radio playback whose header override this layout configured. */ private radioHeaderScopeUrl: string | null = null; private playbackResolution: { - channelId: string; + ownerKey: string; promise: Promise; } | null = null; private lastPlaylistId: string | null | undefined = undefined; @@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { untracked(() => { if (contentType === 'radio') { this.stalkerStore.setRadioChannels([]); - } else if ( - !this.stalkerStore.itvSelectedCategoryFromCache() - ) { + } else if (!this.stalkerStore.itvSelectedCategoryFromCache()) { this.stalkerStore.setItvChannels([]); } this.stalkerStore.setPage(0); @@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // store dedupes per channel id, so this is cheap on rerenders. if (this.supportsEpgMapping && channels.length > 0) { const channelIds = channels.map((channel) => channel.id); - untracked(() => - void this.stalkerStore.applyMappedItvEpg(channelIds) + untracked( + () => void this.stalkerStore.applyMappedItvEpg(channelIds) ); } }); @@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // override slot. this.playbackRequestId += 1; this.streamHeaders.clear(this.radioHeaderScopeUrl); + this.clearActivePlayback(); } isSelectedChannel(item: StalkerItvChannel): boolean { @@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ) { const requestId = ++this.playbackRequestId; const channelId = normalizeStalkerEntityId(item.id); + const sourceId = normalizeStalkerEntityId( + this.stalkerStore.currentPlaylist()?._id + ); + const contentType = this.stalkerStore.selectedContentType(); + const isRadioMode = this.isRadioMode(); this.stalkerStore.setSelectedItem(item); this.ensureChannelWithinRenderWindow(channelId); // A previously owned radio override must not survive into a @@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.radioHeaderScopeUrl = null; try { - const isRadioMode = this.isRadioMode(); const playback = await this.resolvePlaybackForChannel( item, - channelId + { sourceId, contentType, channelId }, + isRadioMode ); - if ( - requestId !== this.playbackRequestId || - this.selectedChannelId() !== channelId - ) { + const owner = { sourceId, contentType, channelId }; + if (!this.isPlaybackRequestCurrent(requestId, owner)) { return; } @@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const stillCurrent = headerSync ? await headerSync : true; if ( !stillCurrent || - requestId !== this.playbackRequestId || - this.selectedChannelId() !== channelId + !this.isPlaybackRequestCurrent(requestId, owner) ) { return; } - this.activePlayback.set(playback); + this.setActivePlayback(playback, null); return; } @@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } if (this.usesEmbeddedPlayer()) { - this.activePlayback.set(playback); + if (!sourceId || !channelId) return; + this.setActivePlayback(playback, { + sourceId, + contentId: channelId, + }); } else if (startPlayback) { void this.portalPlayer.openResolvedPlayback(playback, true); } } catch (error) { - if (requestId !== this.playbackRequestId) { + if ( + !this.isPlaybackRequestCurrent(requestId, { + sourceId, + contentType, + channelId, + }) + ) { return; } @@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private resolvePlaybackForChannel( item: StalkerItvChannel, - channelId: string + owner: StalkerPlaybackResolutionOwner, + isRadioMode: boolean ): Promise { - const playbackChannelId = [ - this.stalkerStore.selectedContentType(), - channelId, - ].join(':'); - if (this.playbackResolution?.channelId === playbackChannelId) { + const ownerKey = JSON.stringify(owner); + if (this.playbackResolution?.ownerKey === ownerKey) { return this.playbackResolution.promise; } const playableItem = this.toPlayableChannel(item); - const promise = this.isRadioMode() + const promise = isRadioMode ? this.stalkerStore.resolveRadioPlayback(playableItem) : this.stalkerStore.resolveItvPlayback(playableItem); - this.playbackResolution = { channelId: playbackChannelId, promise }; + this.playbackResolution = { ownerKey, promise }; const cleanup = () => { if (this.playbackResolution?.promise === promise) { @@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return promise; } + private isPlaybackRequestCurrent( + requestId: number, + owner: StalkerPlaybackResolutionOwner + ): boolean { + return ( + requestId === this.playbackRequestId && + this.selectedChannelId() === owner.channelId && + normalizeStalkerEntityId( + this.stalkerStore.currentPlaylist()?._id + ) === owner.sourceId && + this.stalkerStore.selectedContentType() === owner.contentType + ); + } + + private setActivePlayback( + playback: ResolvedPortalPlayback, + identity: StalkerActiveLivePlaybackIdentity | null + ): void { + this.activePlaybackIdentity.set(identity); + this.activePlayback.set(playback); + } + + private clearActivePlayback(): void { + this.activePlaybackIdentity.set(null); + this.activePlayback.set(null); + } + toggleFavorite(item: StalkerItvChannel) { const itemId = normalizeStalkerEntityId(item.id); if (this.favorites.has(itemId)) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts new file mode 100644 index 000000000..75a22b730 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts @@ -0,0 +1,294 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { + LiveLayoutSidebarStateService, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; +import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { + let fixture: ComponentFixture; + let component: StalkerLiveStreamLayoutComponent; + const playlist = signal({ _id: 'playlist-one', title: 'Portal One' }); + const channels = [ + { + id: 'channel-one', + cmd: 'ffrt4://itv/channel-one', + name: 'One', + o_name: 'One', + logo: 'one.png', + }, + { + id: 'channel-two', + cmd: 'ffrt4://itv/channel-two', + name: 'Two', + o_name: 'Two', + logo: 'two.png', + }, + ]; + const itvChannels = signal(channels); + const selectedItvId = signal(channels[0].id); + const selectedItem = signal<(typeof channels)[number] | null>(channels[0]); + const selectedContentType = signal<'itv' | 'radio'>('itv'); + const resolveItvPlayback = jest.fn(); + const snackBar = { open: jest.fn() }; + const store = { + getSelectedCategoryName: signal('All'), + currentPlaylist: playlist, + selectedContentType, + selectedCategoryId: signal('all'), + selectedItvId, + selectedItem, + itvChannels, + radioChannels: signal([]), + searchPhrase: signal(''), + hasMoreChannels: signal(false), + itvFullListActive: signal(false), + itvSelectedCategoryFromCache: signal(false), + itvFullListLoading: signal(false), + itvFullListProgress: signal(null), + itvFullChannelList: signal([]), + isPaginatedContentLoading: signal(false), + selectedItvEpgPrograms: signal([]), + bulkItvEpgByChannel: signal({}), + isLoadingBulkItvEpg: signal(false), + setItvChannels: jest.fn(), + setRadioChannels: jest.fn(), + setPage: jest.fn(), + preloadItvChannels: jest.fn(), + applyMappedItvEpg: jest.fn(), + clearBulkItvEpgCache: jest.fn(), + ensureBulkItvEpg: jest.fn(), + fetchChannelEpg: jest.fn(), + resolveItvPlayback, + resolveRadioPlayback: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + setSelectedItem: jest.fn((item: (typeof channels)[number]) => { + selectedItem.set(item); + selectedItvId.set(String(item.id)); + }), + }; + + beforeEach(async () => { + playlist.set({ _id: 'playlist-one', title: 'Portal One' }); + selectedContentType.set('itv'); + selectedItvId.set(channels[0].id); + selectedItem.set(channels[0]); + resolveItvPlayback.mockReset(); + snackBar.open.mockReset(); + store.setSelectedItem.mockClear(); + await TestBed.configureTestingModule({ + imports: [StalkerLiveStreamLayoutComponent], + providers: [ + { provide: StalkerStore, useValue: store }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + isElectron: false, + supportsEpgMapping: false, + }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: () => of([]) }, + }, + { + provide: SettingsStore, + useValue: { openStreamOnDoubleClick: signal(false) }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: ElectronStreamHeadersService, + useValue: { apply: jest.fn(), clear: jest.fn() }, + }, + { + provide: LiveLayoutSidebarStateService, + useValue: { isCollapsed: signal(false), toggle: jest.fn() }, + }, + { provide: EpgRuntimeBridgeService, useValue: {} }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }) + .overrideComponent(StalkerLiveStreamLayoutComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + it('changes identity only when the current embedded playback commits', async () => { + const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' }; + resolveItvPlayback.mockResolvedValueOnce(firstPlayback); + await component.playChannel(channels[0]); + const firstKey = component.playbackSessionKey(); + const session = () => [ + component.activePlayback(), + component.playbackSessionKey(), + ]; + expect(session()).toEqual([firstPlayback, firstKey]); + + const failed = deferred(); + resolveItvPlayback.mockReturnValueOnce(failed.promise); + const failedSelection = component.playChannel(channels[1]); + failed.reject(new Error('resolution failed')); + await failedSelection; + expect(session()).toEqual([firstPlayback, firstKey]); + + const stale = deferred(); + const current = deferred(); + resolveItvPlayback + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(current.promise); + const staleSelection = component.playChannel(channels[0]); + const currentSelection = component.playChannel(channels[1]); + const currentPlayback = { + streamUrl: 'https://two.example/live.m3u8', + }; + current.resolve(currentPlayback); + stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' }); + await Promise.all([staleSelection, currentSelection]); + expect(session()).toEqual([ + currentPlayback, + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-one', + contentId: 'channel-two', + }), + ]); + }); + + it('does not dedupe the same channel id across playlist owners', async () => { + const sourceA = deferred(); + const sourceB = deferred(); + resolveItvPlayback + .mockReturnValueOnce(sourceA.promise) + .mockReturnValueOnce(sourceB.promise); + const shared = { ...channels[0], id: 'shared-channel' }; + playlist.set({ _id: 'playlist-a', title: 'Portal A' }); + const requestA = component.playChannel(shared); + playlist.set({ _id: 'playlist-b', title: 'Portal B' }); + const requestB = component.playChannel(shared); + const playbackB = { streamUrl: 'https://b.example/shared.m3u8' }; + + sourceB.resolve(playbackB); + sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' }); + await Promise.all([requestA, requestB]); + + expect(resolveItvPlayback).toHaveBeenCalledTimes(2); + expect(component.activePlayback()).toBe(playbackB); + expect(component.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-b', + contentId: 'shared-channel', + }) + ); + }); + + it('preserves the committed pair when source or channel identity is absent', async () => { + const valid = { streamUrl: 'https://valid.example/live.m3u8' }; + resolveItvPlayback.mockResolvedValueOnce(valid); + await component.playChannel(channels[0]); + const key = component.playbackSessionKey(); + + playlist.set({ _id: '', title: 'Missing source' }); + resolveItvPlayback.mockResolvedValueOnce({ + streamUrl: 'https://invalid.example/source.m3u8', + }); + await component.playChannel(channels[1]); + expect([ + component.activePlayback(), + component.playbackSessionKey(), + ]).toEqual([valid, key]); + + playlist.set({ _id: 'playlist-one', title: 'Portal One' }); + resolveItvPlayback.mockResolvedValueOnce({ + streamUrl: 'https://invalid.example/channel.m3u8', + }); + await component.playChannel({ ...channels[1], id: '' }); + expect([ + component.activePlayback(), + component.playbackSessionKey(), + ]).toEqual([valid, key]); + }); + + it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => { + const pending = deferred(); + resolveItvPlayback.mockReturnValue(pending.promise); + const request = component.playChannel(channels[0]); + + selectedContentType.set('radio'); + pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' }); + await request; + + expect(component.activePlayback()).toBeNull(); + expect(component.playbackSessionKey()).toBe(''); + }); + + it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => { + const pending = deferred(); + resolveItvPlayback.mockReturnValue(pending.promise); + const request = component.playChannel(channels[0]); + + playlist.set({ _id: 'playlist-two', title: 'Portal Two' }); + selectedContentType.set('radio'); + selectedItvId.set(channels[1].id); + pending.reject(new Error('stale owner failure')); + await request; + + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('still reports an error for the current full owner', async () => { + resolveItvPlayback.mockRejectedValueOnce(new Error('current failure')); + + await component.playChannel(channels[0]); + + expect(snackBar.open).toHaveBeenCalledWith( + 'PORTALS.PLAYBACK_ERROR', + undefined, + { duration: 3000 } + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index d2faca0ac..0bdf141a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -12,6 +12,7 @@ @if (showingDetails) { () { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +@Component({ + selector: 'app-stalker-inline-detail', + standalone: true, + template: '', +}) +class StubStalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); + readonly inlinePlayback = input(null); + readonly inlinePlaybackClosed = output(); +} + +describe('StalkerSearchComponent playback session key', () => { + let fixture: ComponentFixture; + const playlist = signal({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + const selectedItem = signal(null); + const resolveVodPlayback = jest.fn(); + const snackBar = { open: jest.fn() }; + + beforeEach(async () => { + playlist.set({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + selectedItem.set(null); + resolveVodPlayback.mockReset(); + snackBar.open.mockReset(); + await TestBed.configureTestingModule({ + imports: [StalkerSearchComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({})), + snapshot: { + data: {}, + queryParamMap: convertToParamMap({}), + routeConfig: { path: 'search' }, + }, + }, + }, + { provide: Location, useValue: { back: jest.fn() } }, + { provide: DataService, useValue: {} }, + { + provide: PlaylistContextFacade, + useValue: { activePlaylist: playlist }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: () => of([]) }, + }, + { + provide: StalkerStore, + useValue: { + selectedItem, + setSelectedContentType: jest.fn(), + setSelectedItem: jest.fn((item) => + selectedItem.set(item) + ), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + resolveVodPlayback, + }, + }, + { provide: StalkerSessionService, useValue: {} }, + { provide: StalkerPortalRepairService, useValue: {} }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }) + .overrideComponent(StalkerSearchComponent, { + set: { + imports: [StubStalkerInlineDetailComponent], + template: ` + @if (showingDetails) { + + } + `, + }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSearchComponent); + }); + + afterEach(() => fixture?.destroy()); + + it('owns the original item identity and threads it unchanged through payload replacement', () => { + const first = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + fixture.componentInstance.selectItem(first); + fixture.detectChanges(); + const expected = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist|one', + contentId: 'movie|one', + }); + const detail = fixture.debugElement.query( + By.directive(StubStalkerInlineDetailComponent) + ).componentInstance as StubStalkerInlineDetailComponent; + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + expect(detail.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replaced.mkv', + title: 'Alternative movie', + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 999, + contentType: 'vod', + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.selectItem({ + ...first, + id: 'movie|two', + }); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + expected + ); + const itemChangedKey = fixture.componentInstance.playbackSessionKey(); + playlist.set({ ...playlist(), _id: 'playlist|two' }); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + itemChangedKey + ); + }); + + it('clears committed playback when the route playlist owner changes for the same provider id', async () => { + const item = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + resolveVodPlayback.mockResolvedValue({ + streamUrl: 'https://a.example/movie.mpg', + }); + fixture.componentInstance.selectItem(item); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + + playlist.set({ ...playlist(), _id: 'playlist|two' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist|two', + contentId: 'movie|one', + }) + ); + }); + + it('suppresses a pending old-playlist error for the same provider id', async () => { + const pending = deferred(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.componentInstance.selectItem({ + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await Promise.resolve(); + + playlist.set({ ...playlist(), _id: 'playlist|two' }); + fixture.detectChanges(); + pending.reject(new Error('stale playlist failure')); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('preserves committed playback when the same canonical owner object refreshes', async () => { + const item = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.componentInstance.selectItem(item); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + + selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 3a3b03f4f..e2bb6f0b1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -6,6 +6,7 @@ import { inject, resource, signal, + untracked, } from '@angular/core'; import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; @@ -57,6 +58,7 @@ import { toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; interface StalkerFilter { key: StalkerSearchContentType; @@ -103,6 +105,7 @@ export class StalkerSearchComponent { private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly logger = createLogger('StalkerSearch'); + private currentPlaybackOwnerKey = ''; readonly filters = signal>({ series: false, @@ -141,6 +144,16 @@ export class StalkerSearchComponent { readonly itemDetails = signal(null); readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); + readonly playbackSessionKey = computed(() => { + const sourceId = this.currentPlaylist()?._id; + const contentId = normalizeStalkerEntityId(this.itemDetails()?.id); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); + private readonly playbackOwnerKey = computed(() => + JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()]) + ); readonly selectedVodPosition = signal(null); readonly selectedVodPlaybackPosition = computed( () => this.selectedVodPosition()?.positionSeconds ?? null @@ -154,6 +167,7 @@ export class StalkerSearchComponent { translateService: this.translateService, logger: this.logger, playbackErrorLogMessage: 'Failed to start search VOD playback', + playbackOwnerKey: () => this.playbackOwnerKey(), }); readonly portalFavorites = createPortalFavoritesResource( @@ -197,16 +211,15 @@ export class StalkerSearchComponent { // executeStalkerRequest owns the portal-mode decision (shared // predicate with URL fallback for legacy rows) and the lazy // portal repair, so search cannot drift from the catalog paths. - const response = - await executeStalkerRequest( - { - dataService: this.dataService, - stalkerSession: this.stalkerSession, - portalRepair: this.portalRepair, - }, - playlist, - requestParams - ); + const response = await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + requestParams + ); const items = response.js?.data || []; return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) @@ -217,6 +230,12 @@ export class StalkerSearchComponent { readonly isSelectedVodFavorite = signal(false); constructor() { + this.currentPlaybackOwnerKey = this.playbackOwnerKey(); + effect(() => { + const ownerKey = this.playbackOwnerKey(); + untracked(() => this.syncPlaybackOwner(ownerKey)); + }); + effect(() => { const routeTerm = this.routeSearchTerm(); if (routeTerm !== this.searchTerm()) { @@ -293,7 +312,6 @@ export class StalkerSearchComponent { } selectItem(item: StalkerVodSource) { - this.closeInlinePlayer(); const filterType = this.selectedFilterType(); const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = @@ -338,6 +356,7 @@ export class StalkerSearchComponent { default: break; } + this.syncPlaybackOwner(this.playbackOwnerKey()); } onVodPlay(item: VodDetailsItem): void { @@ -430,6 +449,12 @@ export class StalkerSearchComponent { ); } + private syncPlaybackOwner(ownerKey: string): void { + if (ownerKey === this.currentPlaybackOwnerKey) return; + this.currentPlaybackOwnerKey = ownerKey; + this.closeInlinePlayer(); + } + inlineDetail() { return createStalkerInlineDetailState( this.itemDetails(), diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts new file mode 100644 index 000000000..9c393b7db --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts @@ -0,0 +1,120 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter'; +import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key'; + +const episodeState = ( + episode: Partial, + seasonKey = 'season|1' +) => ({ + seasonKey, + seasonNumber: 1, + episodeNumber: 2, + episode: { + id: 'synthesized-32-bit-id', + title: 'Episode 2', + episode_num: 2, + season: 1, + ...episode, + } as StalkerMappedEpisode, + previous: null, + next: null, +}); + +describe('createStalkerEpisodePlaybackSessionKey', () => { + it.each([ + STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + ])('uses the complete source command for %s episodes', (seriesMode) => { + const state = episodeState({ originalCmd: 'ffrt4://cmd|full:2' }); + const composite = JSON.stringify([ + seriesMode, + 'parent|series', + state.seasonKey, + state.seasonNumber, + state.episodeNumber, + 'ffrt4://cmd|full:2', + ]); + + expect( + createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist|source', + parentSeriesId: 'parent|series', + seriesMode, + episodeState: state, + }) + ).toBe( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'playlist|source', + contentId: composite, + seriesId: 'parent|series', + seasonNumber: 1, + episodeNumber: 2, + }) + ); + }); + + it('uses the complete provider episode id for lazy VOD episodes', () => { + const state = episodeState({ originalId: 'provider|episode:full' }); + const key = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist', + parentSeriesId: '50001', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + episodeState: state, + }); + + expect(key).toContain('provider|episode:full'); + expect(key).not.toContain('synthesized-32-bit-id'); + }); + + it('separates parent, mode, season, command, and episode identity without delimiter collisions', () => { + const base = { + sourceId: 'playlist', + parentSeriesId: 'parent|a', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: episodeState({ originalCmd: 'cmd|a' }), + } as const; + const variants = [ + { ...base, parentSeriesId: 'parent|b' }, + { + ...base, + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + }, + { + ...base, + episodeState: episodeState( + { originalCmd: 'cmd|a' }, + 'season|2' + ), + }, + { ...base, episodeState: episodeState({ originalCmd: 'cmd|b' }) }, + { + ...base, + episodeState: { + ...base.episodeState, + episodeNumber: 3, + }, + }, + ]; + const baseKey = createStalkerEpisodePlaybackSessionKey(base); + + expect( + variants.map(createStalkerEpisodePlaybackSessionKey) + ).not.toContain(baseKey); + }); + + it.each([episodeState({}), episodeState({ originalCmd: '' })])( + 'fails closed when original episode identity is absent', + (state) => { + expect( + createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: state, + }) + ).toBe(''); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts new file mode 100644 index 000000000..fe706972d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts @@ -0,0 +1,184 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { + normalizeStalkerEntityId, + type StalkerMappedEpisode, +} from '@iptvnator/portal/stalker/data-access'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback'; +import { + STALKER_SERIES_DOWNLOAD_MODES, + type StalkerSeriesDownloadMode, +} from './stalker-series-download.adapter'; + +export interface StalkerEpisodePlaybackSessionKeyOptions { + readonly sourceId: string | null | undefined; + readonly parentSeriesId: unknown; + readonly seriesMode: StalkerSeriesDownloadMode; + readonly episodeState: SeriesPlaybackEpisodeState | null; +} + +export interface StalkerEpisodePlaybackSessionIdentity { + readonly sourceId: string; + readonly parentSeriesId: string; + readonly seriesMode: StalkerSeriesDownloadMode; + readonly originalEpisodeIdentity: string | number; + readonly seasonKey: string; + readonly seasonNumber: number; + readonly episodeNumber: number; + readonly episodeTrackingId: string | number; + readonly sessionKey: string; +} + +interface StalkerEpisodeStateLookupOptions { + readonly episodesBySeason: Record; +} + +function present(value: unknown): value is string | number { + return ( + (typeof value === 'string' && value.trim().length > 0) || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +export function captureStalkerEpisodePlaybackSessionIdentity({ + sourceId, + parentSeriesId, + seriesMode, + episodeState, +}: StalkerEpisodePlaybackSessionKeyOptions): StalkerEpisodePlaybackSessionIdentity | null { + const normalizedSourceId = sourceId?.trim() ?? ''; + const seriesId = normalizeStalkerEntityId(parentSeriesId); + if (!normalizedSourceId || !seriesId || !episodeState) return null; + + const { episodeNumber, seasonKey, seasonNumber } = episodeState; + const episode = episodeState.episode as StalkerMappedEpisode; + const originalIdentity = + seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod + ? episode.originalId + : episode.originalCmd; + if ( + !present(originalIdentity) || + !seasonKey.trim() || + !Number.isSafeInteger(seasonNumber) || + seasonNumber < 0 || + !Number.isSafeInteger(episodeNumber) || + episodeNumber < 1 + ) { + return null; + } + + const contentId = JSON.stringify([ + seriesMode, + seriesId, + seasonKey, + seasonNumber, + episodeNumber, + originalIdentity, + ]); + const sessionKey = createPlaybackSessionKey({ + kind: 'episode', + sourceId: normalizedSourceId, + contentId, + seriesId, + seasonNumber, + episodeNumber, + }); + return Object.freeze({ + sourceId: normalizedSourceId, + parentSeriesId: seriesId, + seriesMode, + originalEpisodeIdentity: originalIdentity, + seasonKey, + seasonNumber, + episodeNumber, + episodeTrackingId: episodeState.episode.id, + sessionKey, + }); +} + +export function createStalkerEpisodePlaybackSessionKey( + options: StalkerEpisodePlaybackSessionKeyOptions +): string { + return ( + captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? '' + ); +} + +export function resolveSelectedStalkerEpisodeState({ + episodesBySeason, + episode, +}: StalkerEpisodeStateLookupOptions & { + readonly episode: XtreamSerieEpisode; +}): SeriesPlaybackEpisodeState | null { + const exactState = findStalkerEpisodeState( + episodesBySeason, + (candidate) => candidate === episode + ); + if (exactState) return exactState; + + const selected = episode as StalkerMappedEpisode; + const originalIdentity = selected.originalId ?? selected.originalCmd; + const selectedSeasonNumber = Number(episode.season) || 0; + if (!present(originalIdentity)) return null; + + return findStalkerEpisodeState(episodesBySeason, (candidate, state) => { + const mapped = candidate as StalkerMappedEpisode; + return ( + (mapped.originalId ?? mapped.originalCmd) === originalIdentity && + (!selectedSeasonNumber || + state.seasonNumber === selectedSeasonNumber) && + state.episodeNumber === Number(episode.episode_num) + ); + }); +} + +export function resolveStalkerEpisodeStateByIdentity({ + episodesBySeason, + identity, +}: StalkerEpisodeStateLookupOptions & { + readonly identity: StalkerEpisodePlaybackSessionIdentity; +}): SeriesPlaybackEpisodeState | null { + return findStalkerEpisodeState( + { [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] }, + (candidate, state) => { + const mapped = candidate as StalkerMappedEpisode; + const originalIdentity = + identity.seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod + ? mapped.originalId + : mapped.originalCmd; + return ( + originalIdentity === identity.originalEpisodeIdentity && + state.seasonNumber === identity.seasonNumber && + state.episodeNumber === identity.episodeNumber + ); + } + ); +} + +function findStalkerEpisodeState( + episodesBySeason: Record, + matches: ( + episode: XtreamSerieEpisode, + state: SeriesPlaybackEpisodeState + ) => boolean +): SeriesPlaybackEpisodeState | null { + for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) { + for ( + let episodeIndex = 0; + episodeIndex < episodes.length; + episodeIndex++ + ) { + const episode = episodes[episodeIndex]; + const state = { + seasonKey, + seasonNumber: Number(episode.season) || Number(seasonKey) || 0, + episodeNumber: Number(episode.episode_num) || episodeIndex + 1, + episode, + previous: episodes[episodeIndex - 1] ?? null, + next: episodes[episodeIndex + 1] ?? null, + }; + if (matches(episode, state)) return state; + } + } + return null; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 1542d6c88..66d0195e1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -167,6 +167,7 @@ @if (inlinePlayback(); as playback) { (); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesTitle = input(null); @@ -771,17 +772,17 @@ describe('StalkerSeriesViewComponent', () => { }), }) ); - expect(inlinePlayer.episodeMetadata()).toEqual({ - label: 'S01E01', - title: 'Pilot', - seasonNumber: 1, - episodeNumber: 1, - }); + // Prettier expands these compact fixtures enough to breach the spec's + // hard 1,200-line lint limit. + // prettier-ignore + expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 }); expect(inlinePlayer.seriesNavigation()).toEqual({ canPrevious: false, canNext: true, autoplayEnabled: true, }); + const firstEpisodeKey = inlinePlayer.playbackSessionKey(); + expect(firstEpisodeKey).not.toBe(''); inlinePlayer.playbackEnded.emit(); await fixture.whenStable(); @@ -806,6 +807,7 @@ describe('StalkerSeriesViewComponent', () => { canNext: false, autoplayEnabled: true, }); + expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey); inlinePlayer.playbackEnded.emit(); await fixture.whenStable(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 3d3d459c8..54b198961 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -61,7 +61,6 @@ import { getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, - resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, type UpNextRailItem, } from '@iptvnator/ui/playback'; @@ -85,6 +84,12 @@ import { createStalkerSeriesDownloadAdapter, STALKER_SERIES_DOWNLOAD_MODES, } from './stalker-series-download.adapter'; +import { + captureStalkerEpisodePlaybackSessionIdentity, + resolveSelectedStalkerEpisodeState, + resolveStalkerEpisodeStateByIdentity, + type StalkerEpisodePlaybackSessionIdentity, +} from './stalker-episode-playback-session-key'; interface SeriesPositionContext { readonly generation: number; @@ -93,46 +98,10 @@ interface SeriesPositionContext { readonly mutationKey: string; } -function snapshotTextList(value: string | undefined): string[] | undefined { - const entries = value - ?.split(',') - .map((entry) => entry.trim()) - .filter(Boolean); - return entries?.length ? entries : undefined; -} - -function snapshotNumber( - value: string | number | undefined -): number | undefined { - if (value === undefined || String(value).trim() === '') { - return undefined; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function snapshotYear(value: string | undefined): number | undefined { - const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); - return match ? Number(match[1]) : undefined; -} - -function snapshotPeople( - enriched: TmdbEnrichedCastMember[] | undefined, - fallback: string | undefined -) { - return enriched?.length - ? enriched.map((person) => ({ - name: person.name, - role: person.character, - profileUrl: person.profileUrl ?? undefined, - tmdbPersonId: person.tmdbPersonId, - })) - : snapshotTextList(fallback)?.map((name) => ({ name })); -} - -function positiveCoordinate(value: number | string | undefined): number { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1; +interface StalkerSeriesPlaybackRequestContext { + readonly generation: number; + readonly usesEmbeddedPlayer: boolean; + readonly identity: StalkerEpisodePlaybackSessionIdentity | null; } /** @@ -215,6 +184,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { >(); private readonly seriesPositionReloadKeys = new Set(); private seriesPositionsLoadGeneration = 0; + private seriesPlaybackRequestGeneration = 0; + private currentSeriesPlaybackOwnerKey = ''; + private readonly inlinePlaybackEpisodeState = + signal | null>(null); private lastSaveTime = 0; private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); @@ -308,6 +281,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading; constructor() { + effect(() => { + const ownerKey = this.seriesPlaybackOwnerKey(); + untracked(() => this.syncSeriesPlaybackOwner(ownerKey)); + }); + // TMDB season fetch, keyed on (tmdb_id, selected season). With season // tabs the first seasonSelected fires immediately when seasons load — // usually BEFORE the async show-level TMDB enrichment has written @@ -426,9 +404,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { ) { return; } - this.episodePlaybackPositions.set( - reconciled.positionsByTrackingId - ); + this.episodePlaybackPositions.set(reconciled.positionsByTrackingId); this.legacyPositionByTrackingId.set( reconciled.legacyPositionByTrackingId ); @@ -488,15 +464,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { // The facade/runtime already saved this row. Repeat the // idempotent upsert because only this view owns the // scoped-to-legacy cleanup mapping. - void this.persistSeriesPosition( - playlistId, - data - ).catch((error: unknown) => { - this.logger.error( - 'Failed to persist runtime series position', - error - ); - }); + void this.persistSeriesPosition(playlistId, data).catch( + (error: unknown) => { + this.logger.error( + 'Failed to persist runtime series position', + error + ); + } + ); } ) ?? null; } @@ -534,6 +509,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer) ); + readonly seriesMode = computed(() => + this.isVodSeries() + ? STALKER_SERIES_DOWNLOAD_MODES.LazyVod + : this.vodWithSeries() + ? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod + : STALKER_SERIES_DOWNLOAD_MODES.RegularSeries + ); + + private readonly seriesPlaybackOwnerKey = computed(() => { + const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? ''; + const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id); + return sourceId && parentSeriesId + ? JSON.stringify([sourceId, parentSeriesId, this.seriesMode()]) + : ''; + }); + readonly episodeDownloadAdapter = computed(() => { const playlist = this.stalkerStore.currentPlaylist(); const item = this.displayItem(); @@ -545,11 +536,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.translateService.defaultLang || 'en', seriesId: this.toSeriesId(item?.id ?? 0), - seriesMode: this.isVodSeries() - ? STALKER_SERIES_DOWNLOAD_MODES.LazyVod - : this.vodWithSeries() - ? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod - : STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + seriesMode: this.seriesMode(), resolveUrl: (command, episodeNumber) => this.stalkerStore.fetchLinkToPlay( playlist?.portalUrl ?? '', @@ -591,10 +578,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { vodSeriesSeasons: this.vodSeriesSeasons(), }); }); - readonly inlineEpisodeState = - computed | null>(() => - this.getInlineEpisodeState() - ); + readonly inlineEpisodeState = computed(() => + this.inlinePlaybackEpisodeState() + ); + readonly playbackSessionKey = signal(''); readonly inlineEpisodeMetadata = computed(() => getSeriesEpisodeMetadata(this.inlineEpisodeState()) ); @@ -769,26 +756,32 @@ export class StalkerSeriesViewComponent implements OnDestroy { */ onEpisodeClicked(episode: XtreamSerieEpisode) { const mappedEpisode = episode as StalkerMappedEpisode; + const item = this.displayItem(); + const episodeState = resolveSelectedStalkerEpisodeState({ + episodesBySeason: this.mappedSeasons(), + episode, + }); + if (!item || !episodeState) return; + this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey()); - if (mappedEpisode.custom_sid === 'vod-series') { - // It's a VOD series episode (is_series=1 mode) - // Use originalId for playback URL, but use generated id for tracking - this.playVodSeriesEpisode({ - originalId: mappedEpisode.originalId, // For constructing playback URL - trackingId: episode.id, // The generated unique ID for position tracking - name: episode.title, - series_number: episode.episode_num, - }); - } else { - // Regular series or vclub mode (VOD with embedded series array) - // Use originalCmd for playback, episode.id for tracking - const trackingId = Number(episode.id); - this.playEpisodeClicked( - episode.episode_num, - mappedEpisode.originalCmd, - trackingId - ); - } + const isLazyVod = mappedEpisode.custom_sid === 'vod-series'; + const command = isLazyVod + ? `/media/file_${mappedEpisode.originalId ?? ''}.mpg` + : mappedEpisode.originalCmd; + const title = isLazyVod + ? `${item.info.name} - ${episode.title || `Episode ${episodeState.episodeNumber}`}` + : item.info.name; + const trackingId = Number(episode.id); + const startTime = + this.episodePlaybackPositions().get(trackingId)?.positionSeconds; + + void this.startPlayback( + command, + title, + item.info.movie_image, + episodeState, + startTime + ); } async playQuickStartEpisode(): Promise { @@ -807,69 +800,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - /** - * Play episode - handles regular series and vclub mode - */ - playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) { - const item = this.displayItem(); - if (!item) return; - this.logger.debug('playEpisodeClicked', { - episodeNum, - cmd, - trackingId, - seriesId: item.id, - }); - - const position = trackingId - ? this.episodePlaybackPositions().get(trackingId) - : undefined; - const startTime = position?.positionSeconds; - void this.startPlayback( - cmd, - item.info.name, - item.info.movie_image, - episodeNum, - trackingId, - startTime - ); - } - - /** - * Play VOD series episode - */ - playVodSeriesEpisode(episode: { - originalId?: string; - trackingId: string; - name: string; - series_number: number; - }) { - const item = this.displayItem(); - if (!item) return; - // Use originalId for playback URL (this is what the Stalker API expects) - const cmd = `/media/file_${episode.originalId ?? ''}.mpg`; - const episodeName = episode.name || `Episode ${episode.series_number}`; - // Use trackingId (generated unique ID) for playback position tracking - const trackingId = Number(episode.trackingId); - - this.logger.debug('playVodSeriesEpisode', { - originalId: episode.originalId, - trackingId, - seriesId: item.id, - episodeName, - }); - - const position = this.episodePlaybackPositions().get(trackingId); - const startTime = position?.positionSeconds; - void this.startPlayback( - cmd, - `${item.info.name} - ${episodeName}`, - item.info.movie_image, - episode.series_number, - trackingId, - startTime - ); - } - openSimilarInPortals(item: CrossPortalSimilarItem): void { void this.router.navigate(this.crossPortalSimilar.buildLink(item)); } @@ -907,7 +837,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { } closeInlinePlayer(): void { + this.seriesPlaybackRequestGeneration += 1; this.inlinePlayback.set(null); + this.inlinePlaybackEpisodeState.set(null); + this.playbackSessionKey.set(''); this.lastSaveTime = 0; } @@ -984,13 +917,27 @@ export class StalkerSeriesViewComponent implements OnDestroy { } private async startPlayback( - cmd?: string, - title?: string, - thumbnail?: string, - episodeNum?: number, - episodeId?: number, + cmd: string | undefined, + title: string | undefined, + thumbnail: string | undefined, + episodeState: SeriesPlaybackEpisodeState, startTime?: number ): Promise { + const generation = ++this.seriesPlaybackRequestGeneration; + const episodeNum = episodeState.episodeNumber; + const episodeId = Number(episodeState.episode.id); + const request: StalkerSeriesPlaybackRequestContext = { + generation, + usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(), + identity: captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: this.stalkerStore.currentPlaylist()?._id, + parentSeriesId: this.displayItem()?.id, + seriesMode: this.seriesMode(), + episodeState, + }), + }; + if (request.usesEmbeddedPlayer && !request.identity) return; + try { const playback = await this.stalkerStore.resolveVodPlayback( cmd, @@ -1000,14 +947,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { episodeId, startTime ); - const episodeState = - episodeId === undefined - ? null - : resolveSeriesPlaybackEpisodeState({ - episodesBySeason: this.mappedSeasons(), - currentEpisodeId: episodeId, - fallbackEpisodeNumber: episodeNum, - }); + if (!this.isPlaybackRequestCurrent(request)) return; + const resolvedPlayback = episodeState && playback.contentInfo?.contentType === 'episode' ? { @@ -1021,14 +962,19 @@ export class StalkerSeriesViewComponent implements OnDestroy { : playback; this.lastSaveTime = 0; - if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(resolvedPlayback); + if (request.usesEmbeddedPlayer && request.identity) { + this.setInlinePlayback( + resolvedPlayback, + request.identity.sessionKey, + episodeState + ); return; } this.closeInlinePlayer(); void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true); } catch (error) { + if (!this.isPlaybackRequestCurrent(request)) return; this.logger.error('Failed to start inline series playback', error); const errorMessage = error instanceof Error && error.message === 'nothing_to_play' @@ -1042,25 +988,47 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - private getInlineEpisodeState(): SeriesPlaybackEpisodeState | null { - const playback = this.inlinePlayback(); - const currentEpisodeId = playback?.contentInfo?.contentXtreamId; - - if ( - playback?.contentInfo?.contentType !== 'episode' || - currentEpisodeId === undefined - ) { - return null; + private isPlaybackRequestCurrent( + request: StalkerSeriesPlaybackRequestContext + ): boolean { + if (request.generation !== this.seriesPlaybackRequestGeneration) { + return false; } + if (!request.identity) return true; - return resolveSeriesPlaybackEpisodeState({ + const identity = request.identity; + const episodeState = resolveStalkerEpisodeStateByIdentity({ episodesBySeason: this.mappedSeasons(), - currentEpisodeId, + identity, }); + const currentIdentity = captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: this.stalkerStore.currentPlaylist()?._id, + parentSeriesId: this.displayItem()?.id, + seriesMode: this.seriesMode(), + episodeState, + }); + return currentIdentity?.sessionKey === identity.sessionKey; } ngOnDestroy(): void { this.unsubscribePositionUpdates?.(); + this.closeInlinePlayer(); + } + + private setInlinePlayback( + playback: ResolvedPortalPlayback, + sessionKey: string, + episodeState: SeriesPlaybackEpisodeState | null + ): void { + this.playbackSessionKey.set(sessionKey); + this.inlinePlaybackEpisodeState.set(episodeState); + this.inlinePlayback.set(playback); + } + + private syncSeriesPlaybackOwner(ownerKey: string): void { + if (ownerKey === this.currentSeriesPlaybackOwnerKey) return; + this.currentSeriesPlaybackOwnerKey = ownerKey; + this.closeInlinePlayer(); } async handlePlaybackToggleRequested( @@ -1072,17 +1040,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { } if (request.nextPosition) { - await this.persistSeriesPosition( - playlistId, - request.nextPosition - ); + await this.persistSeriesPosition(playlistId, request.nextPosition); return; } - await this.clearSeriesPosition( - playlistId, - request.contentXtreamId - ); + await this.clearSeriesPosition(playlistId, request.contentXtreamId); } handlePlaybackToggleRequestedFromUi( @@ -1104,9 +1066,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { const generation = ++this.seriesPositionsLoadGeneration; this.trackPendingSeriesPositionLoad(context, generation); try { - await this.waitForSeriesPositionMutations( - context.mutationKey - ); + await this.waitForSeriesPositionMutations(context.mutationKey); if ( generation !== this.seriesPositionsLoadGeneration || @@ -1155,16 +1115,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { return ( activeContext === context && activeContext.generation === context.generation && - this.stalkerStore.currentPlaylist()?._id === - context.playlistId && + this.stalkerStore.currentPlaylist()?._id === context.playlistId && this.toSeriesId(this.displayItem()?.id ?? 0) === context.seriesXtreamId ); } - private waitForSeriesPositionMutations( - mutationKey: string - ): Promise { + private waitForSeriesPositionMutations(mutationKey: string): Promise { return ( this.seriesPositionMutationQueues.get(mutationKey) ?? Promise.resolve() @@ -1176,8 +1133,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { generation: number ): void { const generations = - this.pendingSeriesPositionLoads.get(context) ?? - new Set(); + this.pendingSeriesPositionLoads.get(context) ?? new Set(); generations.add(generation); this.pendingSeriesPositionLoads.set(context, generations); } @@ -1186,8 +1142,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { context: SeriesPositionContext, generation: number ): void { - const generations = - this.pendingSeriesPositionLoads.get(context); + const generations = this.pendingSeriesPositionLoads.get(context); generations?.delete(generation); if (generations?.size === 0) { this.pendingSeriesPositionLoads.delete(context); @@ -1220,30 +1175,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { () => undefined, () => undefined ); - this.seriesPositionMutationQueues.set( - context.mutationKey, - barrier - ); + this.seriesPositionMutationQueues.set(context.mutationKey, barrier); void barrier.then(() => { if ( - this.seriesPositionMutationQueues.get( - context.mutationKey - ) === barrier + this.seriesPositionMutationQueues.get(context.mutationKey) === + barrier ) { - this.seriesPositionMutationQueues.delete( - context.mutationKey - ); - this.reloadSeriesPositionsAfterMutations( - context.mutationKey - ); + this.seriesPositionMutationQueues.delete(context.mutationKey); + this.reloadSeriesPositionsAfterMutations(context.mutationKey); } }); return result; } - private reloadSeriesPositionsAfterMutations( - mutationKey: string - ): void { + private reloadSeriesPositionsAfterMutations(mutationKey: string): void { if (!this.seriesPositionReloadKeys.delete(mutationKey)) { return; } @@ -1286,10 +1231,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (!context) { return Promise.resolve(); } - const legacyPosition = - this.legacyPositionByTrackingId().get( - position.contentXtreamId - ); + const legacyPosition = this.legacyPositionByTrackingId().get( + position.contentXtreamId + ); return this.enqueueSeriesPositionMutation(context, async () => { let clearedLegacy: boolean; try { @@ -1301,8 +1245,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { }); } catch (error) { if ( - error instanceof - StalkerSeriesPositionPartialSaveError && + error instanceof StalkerSeriesPositionPartialSaveError && this.isSeriesPositionContextActive(context) ) { this.publishSavedSeriesPosition( @@ -1329,14 +1272,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { legacyPosition: PlaybackPositionData | undefined, clearedLegacy: boolean ): void { - const removedTrackingIds = new Set([ - position.contentXtreamId, - ]); + const removedTrackingIds = new Set([position.contentXtreamId]); if (clearedLegacy && legacyPosition) { removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map( - this.legacyPositionByTrackingId() - ); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); legacyPositions.delete(position.contentXtreamId); this.legacyPositionByTrackingId.set(legacyPositions); } @@ -1344,9 +1283,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.rawSeriesPositions.set([ ...this.rawSeriesPositions().filter( (candidate) => - !removedTrackingIds.has( - candidate.contentXtreamId - ) + !removedTrackingIds.has(candidate.contentXtreamId) ), position, ]); @@ -1361,14 +1298,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (!context) { return Promise.resolve(); } - const position = - this.episodePlaybackPositions().get(contentXtreamId) ?? { - contentXtreamId, - contentType: 'episode', - positionSeconds: 0, - playlistId, - seriesXtreamId: context.seriesXtreamId, - }; + const position = this.episodePlaybackPositions().get( + contentXtreamId + ) ?? { + contentXtreamId, + contentType: 'episode', + positionSeconds: 0, + playlistId, + seriesXtreamId: context.seriesXtreamId, + }; const legacyPosition = this.legacyPositionByTrackingId().get(contentXtreamId); return this.enqueueSeriesPositionMutation(context, async () => { @@ -1397,9 +1335,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { const removedTrackingIds = new Set([contentXtreamId]); if (clearedLegacy && legacyPosition) { removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map( - this.legacyPositionByTrackingId() - ); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); legacyPositions.delete(contentXtreamId); this.legacyPositionByTrackingId.set(legacyPositions); } @@ -1407,9 +1343,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.rawSeriesPositions.set( this.rawSeriesPositions().filter( (candidate) => - !removedTrackingIds.has( - candidate.contentXtreamId - ) + !removedTrackingIds.has(candidate.contentXtreamId) ) ); this.removeEpisodePlaybackPosition(contentXtreamId); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts new file mode 100644 index 000000000..aaf0e0c0e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts @@ -0,0 +1,479 @@ +import { EMPTY, of } from 'rxjs'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerMappedEpisode, + type StalkerVodSource, +} from '@iptvnator/portal/stalker/data-access'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter'; +import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('StalkerSeriesViewComponent playback session key', () => { + let fixture: ComponentFixture; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasons = signal([]); + const vodSeasons = signal([]); + const resolveVodPlayback = jest.fn(); + const currentPlaylist = signal({ + _id: 'stalker|playlist', + title: 'Portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + + beforeEach(async () => { + selectedContentType.set('series'); + selectedItem.set({ + id: 'series|parent', + info: { name: 'Series', movie_image: 'poster.jpg' }, + }); + serialSeasons.set([ + { + id: 'regular-season', + name: 'Season 1', + cmd: 'ffrt4://regular|full-command', + series: [1, 2], + }, + ]); + vodSeasons.set([]); + resolveVodPlayback + .mockReset() + .mockImplementation( + async ( + _command: string, + title: string, + thumbnail: string, + _episodeNumber: number, + episodeId: number + ) => ({ + streamUrl: 'https://resolved.example/temporary.mpg', + title, + thumbnail, + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episodeId, + contentType: 'episode', + seriesXtreamId: 999, + }, + }) + ); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => serialSeasons(), + getVodSeriesSeasonsResource: () => vodSeasons(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes: jest.fn(), + resolveVodPlayback, + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: jest + .fn() + .mockResolvedValue([]), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => fixture.destroy()); + + async function playFirstEpisode(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + const episode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(episode); + await fixture.whenStable(); + return episode; + } + + function setLazySeries(parentId: string, episodeId: string): void { + selectedContentType.set('vod'); + selectedItem.set({ + id: parentId, + is_series: true, + info: { name: 'Lazy series', movie_image: 'poster.jpg' }, + }); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'lazy-season', + video_id: parentId, + season_number: '2', + name: 'Season 2', + episodes: [ + { + id: episodeId, + series_number: 3, + name: 'Replacement episode', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + } + + it.each([ + STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + ])('owns a collision-safe key for %s playback', async (expectedMode) => { + if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod) { + fixture.componentRef.setInput('vodWithSeries', { + id: 'series|parent', + cmd: 'ffrt4://embedded|full-command', + series: [1, 2], + info: { name: 'Embedded series', movie_image: 'poster.jpg' }, + }); + } else if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod) { + selectedContentType.set('vod'); + selectedItem.set({ + id: 'series|parent', + is_series: true, + info: { name: 'Lazy series', movie_image: 'poster.jpg' }, + }); + vodSeasons.set([]); + fixture.detectChanges(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season|provider', + video_id: 'series|parent', + season_number: '1', + name: 'Season | One', + episodes: [ + { + id: 'episode|provider:full', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + } + + const episode = await playFirstEpisode(); + const key = fixture.componentInstance.playbackSessionKey(); + + expect(fixture.componentInstance.seriesMode()).toBe(expectedMode); + expect(key).not.toBe(''); + expect(key).not.toContain(String(episode.id)); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replacement.mkv', + title: 'Replacement payload', + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 987654321, + contentType: 'episode', + seriesXtreamId: 123, + seasonNumber: 9, + episodeNumber: 9, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); + + it('changes when the original full episode command changes', async () => { + await playFirstEpisode(); + const firstKey = fixture.componentInstance.playbackSessionKey(); + serialSeasons.set([ + { + id: 'replacement-season', + name: 'Season 1', + cmd: 'ffrt4://different|full-command', + series: [1], + }, + ]); + + await playFirstEpisode(); + + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + firstKey + ); + }); + + it('selects the exact clicked episode when distinct provider commands collide in the tracking hash', async () => { + serialSeasons.set([ + { + id: 'collision-a', + name: 'Season A', + cmd: 'collision-cmd-1000', + series: [1], + }, + { + id: 'collision-b', + name: 'Season B', + cmd: 'collision-cmd-425721', + series: [1], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const [episodeA, episodeB] = Object.values( + fixture.componentInstance.mappedSeasons() + ).map((episodes) => episodes[0] as StalkerMappedEpisode); + + expect(episodeA.id).toBe('1511026353'); + expect(episodeB.id).toBe(episodeA.id); + + fixture.componentInstance.onEpisodeClicked(episodeB); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + 'collision-cmd-425721', + 'Series', + 'poster.jpg', + 1, + 1511026353, + undefined + ); + const expectedBKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: '2', + seasonNumber: 2, + episodeNumber: 1, + episode: episodeB, + previous: null, + next: null, + }, + }); + const wrongAKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: '1', + seasonNumber: 1, + episodeNumber: 1, + episode: episodeA, + previous: null, + next: null, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + expectedBKey + ); + expect(expectedBKey).not.toBe(wrongAKey); + }); + + it.each(['parent', 'mode', 'playlist'] as const)( + 'clears committed playback when the canonical %s owner changes', + async (transition) => { + await playFirstEpisode(); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + + if (transition === 'parent') { + selectedItem.set({ + ...selectedItem()!, + id: 'replacement-parent', + }); + } else if (transition === 'mode') { + selectedContentType.set('vod'); + selectedItem.set({ ...selectedItem()!, is_series: true }); + } else { + currentPlaylist.set({ + ...currentPlaylist(), + _id: 'replacement-playlist', + }); + } + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + } + ); + + it('preserves committed playback across a same-owner object refresh', async () => { + await playFirstEpisode(); + const playback = fixture.componentInstance.inlinePlayback(); + const key = fixture.componentInstance.playbackSessionKey(); + + selectedItem.set({ ...selectedItem()! }); + currentPlaylist.set({ ...currentPlaylist() }); + serialSeasons.set( + serialSeasons().map((season) => ({ + ...(season as Record), + })) + ); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); + + it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const pending = deferred(); + resolveVodPlayback.mockReturnValueOnce(pending.promise); + const episode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(episode); + + currentPlaylist.set({ + ...currentPlaylist(), + _id: 'replacement-playlist', + }); + setLazySeries('replacement-parent', 'replacement-provider-episode'); + pending.resolve({ + streamUrl: 'https://stale.example/old-episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episode.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + }); + + it('keeps the newest episode when owner-scoped requests resolve out of order', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const oldRequest = deferred(); + const newRequest = deferred(); + resolveVodPlayback + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(newRequest.promise); + const oldEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(oldEpisode); + + currentPlaylist.set({ ...currentPlaylist(), _id: 'new-playlist' }); + setLazySeries('new-parent', 'new-provider-episode'); + const newEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(newEpisode); + const newestPlayback = { + streamUrl: 'https://new.example/episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: newEpisode.id, + contentType: 'episode' as const, + }, + }; + newRequest.resolve(newestPlayback); + oldRequest.resolve({ + streamUrl: 'https://old.example/episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: oldEpisode.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe( + newestPlayback.streamUrl + ); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe(''); + }); + + it('preserves a valid mounted episode when original identity is missing', async () => { + await playFirstEpisode(); + const playback = fixture.componentInstance.inlinePlayback(); + const key = fixture.componentInstance.playbackSessionKey(); + serialSeasons.set([ + { + id: 'identity-less-season', + name: 'Season 1', + series: [1], + }, + ]); + + await playFirstEpisode(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts index ba1c94ae5..152bcb820 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -80,6 +80,7 @@ function createController() { return { controller, + inlinePlayback, playbackPositions, selectedVodPosition, }; @@ -115,4 +116,31 @@ describe('StalkerVodPlaybackController', () => { expect(selectedVodPosition()?.contentXtreamId).toBe(202); expect(selectedVodPosition()?.positionSeconds).toBe(20); }); + + it('does not mount playback that resolves after the detail closes', async () => { + const { controller, inlinePlayback } = createController(); + const pending = createDeferred<{ streamUrl: string }>(); + const playback = controller.startVodPlayback(() => pending.promise); + + controller.closeInlinePlayer(); + pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await playback; + + expect(inlinePlayback()).toBeNull(); + }); + + it('keeps the newest VOD request when resolutions finish out of order', async () => { + const { controller, inlinePlayback } = createController(); + const older = createDeferred<{ streamUrl: string }>(); + const newer = createDeferred<{ streamUrl: string }>(); + const olderRequest = controller.startVodPlayback(() => older.promise); + const newerRequest = controller.startVodPlayback(() => newer.promise); + const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' }; + + newer.resolve(newestPlayback); + older.resolve({ streamUrl: 'https://old.example/movie.mpg' }); + await Promise.all([olderRequest, newerRequest]); + + expect(inlinePlayback()).toBe(newestPlayback); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index ed1f5df40..2d477d356 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -21,22 +21,30 @@ interface StalkerVodPlaybackControllerConfig { translateService: TranslateService; logger: Logger; playbackErrorLogMessage: string; + playbackOwnerKey?: () => string; } export class StalkerVodPlaybackController { private lastInlineSaveTime = 0; private loadSelectedVodPositionRequestId = 0; + private playbackRequestId = 0; constructor(private readonly config: StalkerVodPlaybackControllerConfig) {} async startVodPlayback( resolvePlayback: () => Promise ): Promise { + const requestId = ++this.playbackRequestId; + const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer(); + const playbackOwnerKey = this.config.playbackOwnerKey?.(); try { const playback = await resolvePlayback(); + if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) { + return; + } this.lastInlineSaveTime = 0; - if (this.config.portalPlayer.isEmbeddedPlayer()) { + if (usesEmbeddedPlayer) { this.config.inlinePlayback.set(playback); return; } @@ -44,6 +52,9 @@ export class StalkerVodPlaybackController { this.closeInlinePlayer(); void this.config.portalPlayer.openResolvedPlayback(playback, true); } catch (error) { + if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) { + return; + } this.config.logger.error( this.config.playbackErrorLogMessage, error @@ -115,10 +126,22 @@ export class StalkerVodPlaybackController { } closeInlinePlayer(): void { + this.playbackRequestId += 1; this.config.inlinePlayback.set(null); this.lastInlineSaveTime = 0; } + private isPlaybackRequestCurrent( + requestId: number, + playbackOwnerKey: string | undefined + ): boolean { + return ( + requestId === this.playbackRequestId && + (this.config.playbackOwnerKey === undefined || + this.config.playbackOwnerKey() === playbackOwnerKey) + ); + } + showCopyNotification(): void { this.config.snackBar.open( this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'), diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 8b2d57657..61b006185 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -96,6 +96,7 @@ @if (isEmbeddedPlayer) {
(); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be @@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => { }; const portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(true), + openExternalPlayback: jest.fn(), }; const settingsStore = { openStreamOnDoubleClick: signal(false), @@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => { const originalElectron = window.electron; beforeEach(async () => { + currentPlaylist.set(playlist); jest.useFakeTimers(); jest.setSystemTime(fixedNow); settingsStore.resolvedEpgViewMode.set('timeline'); @@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => { xtreamUrlService.resolveCatchupUrl.mockClear(); portalPlayer.isEmbeddedPlayer.mockReset(); portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + portalPlayer.openExternalPlayback.mockClear(); epgItems.set([]); currentEpgItem.set(null); @@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => { ).not.toBeNull(); }); + it('keeps the host-owned live key across URL replacement and changes it with the channel', () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + currentPlaylist.set({ ...playlist, id: 'playlist|one' }); + const first = { ...sampleChannel, xtream_id: 101 }; + component.playLive(first); + fixture.detectChanges(); + + const webPlayer = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist|one', + contentId: 101, + }); + expect(webPlayer.playbackSessionKey()).toBe(expected); + + xtreamStore.constructStreamUrl.mockReturnValueOnce( + 'https://example.com/replaced-timeshift.ts' + ); + component.playLive(first); + fixture.detectChanges(); + expect(webPlayer.playbackSessionKey()).toBe(expected); + + component.playLive({ ...first, xtream_id: 102 }); + fixture.detectChanges(); + expect(webPlayer.playbackSessionKey()).not.toBe(expected); + }); + + it('forwards the exact resolved live playback to external fallback', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/fallback.m3u8', + title: 'Channel 101', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 101, + contentType: 'live', + }, + }; + component.handleExternalFallbackRequest({ + player: 'mpv', + playback, + } as PlaybackFallbackRequest); + + const [forwardedPlayback, forwardedPlayer] = + portalPlayer.openExternalPlayback.mock.calls[0]; + expect(forwardedPlayback).toBe(playback); + expect(forwardedPlayer).toBe('mpv'); + }); + it('updates live root pagination from the header paginator', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); @@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => { ); }); - it('shows the active archive program in the live EPG panel summary', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, + it('does not attach a pending catchup result to a newer live owner', async () => { + let resolveCatchup!: (url: string) => void; + xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce( + new Promise((resolve) => { + resolveCatchup = resolve; + }) + ); + component.playLive(sampleChannel); + const catchup = component.onProgramActivated({ + type: 'timeshift', + program: buildArchivedProgram(), + }); + const newerChannel = { + ...sampleChannel, + xtream_id: 202, + name: 'Channel 202', }; + xtreamStore.constructStreamUrl.mockReturnValueOnce( + 'https://example.com/channel-202.ts' + ); + component.playLive(newerChannel); + resolveCatchup('https://stale.example/channel-101-timeshift.ts'); + await catchup; + + expect(component.activePlayback()?.streamUrl).toBe( + 'https://example.com/channel-202.ts' + ); + expect(component.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-1', + contentId: 202, + }) + ); + }); + + it('shows the active archive program in the live EPG panel summary', async () => { + const archivedProgram = buildArchivedProgram(); epgItems.set([ buildEpgItem( 'archived', @@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('returns archive playback to the selected live stream from the panel action', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); currentEpgItem.set( buildEpgItem( 'current', @@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => { isLive: true, }) ); - expect(timeline.componentInstance.summary()?.title).toBe('Current Show'); + expect(timeline.componentInstance.summary()?.title).toBe( + 'Current Show' + ); expect(timeline.componentInstance.summaryLabelKey()).toBe( 'EPG.CURRENT_PROGRAM' ); @@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('publishes the active archive program in remote-control status', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); currentEpgItem.set( buildEpgItem( 'current', @@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('passes the active catchup program to the EPG list until live playback resumes', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); epgItems.set([ buildEpgItem( '1', @@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => { const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) ); - expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true); + expect(timeline.componentInstance.archivePlaybackAvailable()).toBe( + true + ); }); it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => { @@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => { }); }); +const buildArchivedProgram = (): EpgProgram => ({ + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, +}); + function buildEpgItem( id: string, title: string, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index cec491234..bbb34c0c4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; + private playbackRequestId = 0; readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); + private readonly activeLiveItemId = signal(null); + readonly playbackSessionKey = computed(() => { + const sourceId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.activeLiveItemId(); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; + }); readonly activeStreamUrl = computed( () => this.activePlayback()?.streamUrl ?? '' ); @@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { item: XtreamLiveChannelItem, startPlayback = !this.settingsStore.openStreamOnDoubleClick() ) { + this.playbackRequestId += 1; const streamUrl = this.xtreamStore.constructStreamUrl(item); this.activeCatchupProgram.set(null); // Keep both root/recently-added playback and same-category replays in // sync with the category rail. For already-selected channels this is a // store no-op. this.selectLiveItemCategory(item); + this.activeLiveItemId.set(item.xtream_id); this.activePlayback.set({ streamUrl, title: item.title ?? item.name ?? '', @@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.playbackRequestId += 1; this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); } @@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } + const requestId = ++this.playbackRequestId; + this.activeLiveItemId.set(item.xtream_id); const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl( playlist.id, { @@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { stopTimestamp, playlist.serverTimezone ); + if ( + requestId !== this.playbackRequestId || + this.xtreamStore.currentPlaylist()?.id !== playlist.id || + this.activeLiveItemId() !== item.xtream_id + ) { + return; + } this.activeCatchupProgram.set(program); this.activePlayback.set({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 8669ed032..134a8378e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService { private lastSaveTime = 0; readonly inlinePlayback = signal(null); + readonly inlinePlaybackSessionEpisodeState = + signal | null>(null); readonly episodePlaybackPositions = this.playbackPositionState.positions; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(null); @@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService { contentInfo, }; - this.startPlayback(playback); + const episodeState = resolveSeriesPlaybackEpisodeState({ + episodesBySeason: selectedItem.episodes, + currentEpisodeId: episode.id, + fallbackSeasonNumber: Number(episode.season), + fallbackEpisodeNumber: Number(episode.episode_num), + }); + this.startPlayback(playback, episodeState); } playQuickStartEpisode(): void { @@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService { closeInlinePlayer(): void { this.inlinePlayback.set(null); + this.inlinePlaybackSessionEpisodeState.set(null); this.lastSaveTime = 0; } @@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService { }); } - private startPlayback(playback: ResolvedPortalPlayback): void { + private startPlayback( + playback: ResolvedPortalPlayback, + episodeState: SeriesPlaybackEpisodeState | null + ): void { this.lastSaveTime = 0; if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlaybackSessionEpisodeState.set(episodeState); this.inlinePlayback.set(playback); return; } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index cbe1231ba..12ca6c012 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -179,6 +179,7 @@ @if (inlinePlayback(); as playback) { (); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesTitle = input(null); @@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => { let seriesResumeTarget: ReturnType< typeof signal >; + let routeParams: BehaviorSubject<{ + categoryId: string; + serialId: string; + }>; beforeEach(async () => { window.history.replaceState({}, '', window.location.href); @@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => { getSeriesPlaybackPositions.mockClear(); getSeriesPlaybackPositions.mockResolvedValue([]); seriesResumeTarget = signal(null); + routeParams = new BehaviorSubject({ + categoryId: '3', + serialId: '103', + }); await TestBed.configureTestingModule({ imports: [SerialDetailsComponent], @@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => { { provide: ActivatedRoute, useValue: { - params: of({ - categoryId: '3', - serialId: '103', - }), + params: routeParams, snapshot: { params: { categoryId: '3', @@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => { seasonNumber: 1, episodeNumber: 1, }); + const firstEpisodeKey = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 103, + seasonNumber: 1, + episodeNumber: 1, + }); + expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey); expect(inlinePlayer.seriesNavigation()).toEqual({ canPrevious: false, canNext: true, @@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => { canNext: false, autoplayEnabled: true, }); + expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey); inlinePlayer.playbackEnded.emit(); fixture.detectChanges(); @@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => { expect.objectContaining({ id: '2001' }) ); }); + + it('owns the parent identity from the route and ignores replacement playback payloads', async () => { + isEmbeddedPlayer.mockReturnValue(true); + fixture.detectChanges(); + await fixture.whenStable(); + const item = fixture.componentInstance.selectedItem(); + const episode = item?.episodes?.['1'][0]; + if (!item || !episode) { + throw new Error('Expected the serial fixture and first episode'); + } + fixture.componentInstance.playEpisode(episode); + fixture.detectChanges(); + const expected = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 103, + seasonNumber: 1, + episodeNumber: 1, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replaced.mkv', + title: 'Alternative payload', + headers: { Authorization: 'Bearer replacement' }, + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 999001, + contentType: 'episode', + seriesXtreamId: 999, + seasonNumber: 9, + episodeNumber: 9, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + selectedItem.set({ ...item, series_id: 999 }); + routeParams.next({ categoryId: '3', serialId: '104' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.playEpisode(episode); + fixture.detectChanges(); + + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 104, + seasonNumber: 1, + episodeNumber: 1, + }) + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index f004af19b..382c16ff1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -53,6 +53,7 @@ import { } from '../tmdb-similar.util'; import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; +import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; @Component({ selector: 'app-serial-details', @@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly quickStartAction = this.playback.quickStartAction; readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata; readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation; + readonly playbackSessionKey = computed(() => + createSerialPlaybackSessionKey( + this.xtreamStore.currentPlaylist()?.id, + this.routeParams().serialId, + this.playback.inlinePlaybackSessionEpisodeState() + ) + ); /** "Up Next" rail entries for the inline player (series only). */ readonly upNextEpisodes = computed(() => buildUpNextRailItems({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts new file mode 100644 index 000000000..fe7924547 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts @@ -0,0 +1,28 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback'; + +export function createSerialPlaybackSessionKey( + sourceId: string | undefined, + seriesId: number | string | undefined, + episodeState: SeriesPlaybackEpisodeState | null +): string { + const normalizedSeriesId = Number(seriesId); + if ( + !sourceId || + !Number.isSafeInteger(normalizedSeriesId) || + normalizedSeriesId <= 0 || + !episodeState + ) { + return ''; + } + + return createPlaybackSessionKey({ + kind: 'episode', + sourceId, + contentId: episodeState.episode.id, + seriesId: normalizedSeriesId, + seasonNumber: episodeState.seasonNumber, + episodeNumber: episodeState.episodeNumber, + }); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 250f71fed..e24bc806d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -27,6 +27,7 @@ import { import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, NEVER, of } from 'rxjs'; import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-portal-inline-player', @@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component'; template: '
', }) class StubPortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); readonly playback = input(null); // Multi-source wiring: the real player takes these, so the stub has to // accept them or the template binding fails to compile. @@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => { thumbnail: 'https://example.com/catalog-poster.jpg', }) ); + const expectedKey = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist-1', + contentId: 650020, + }); + expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://copy.example/movie/9001.mkv', + title: 'Catalog movie', + headers: { 'User-Agent': 'Copy Provider' }, + contentInfo: { + playlistId: 'copy-playlist', + contentXtreamId: 9001, + contentType: 'vod', + }, + }); + fixture.detectChanges(); + expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey); expect( host.querySelector('app-portal-detail-shell')?.classList ).toContain('shell-host--watch'); @@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { selectedItem.set(richItem()); fixture.detectChanges(); expect(fixture.componentInstance.providerOnly()).toBe(true); + const firstSessionKey = fixture.componentInstance.playbackSessionKey(); window.history.replaceState({}, '', window.location.href); selectedItem.set({ @@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => { fixture.detectChanges(); expect(fixture.componentInstance.providerOnly()).toBe(false); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + firstSessionKey + ); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist-1', + contentId: 650021, + }) + ); }); it('plays a rich downloaded movie locally without a usable provider source', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 536d415e6..c1b46def5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -254,6 +254,7 @@ @if (playableVodItem()) { @if (inlinePlayback(); as playback) { Number(this.routeParams().vodId)); + readonly playbackSessionKey = computed(() => { + const sourceId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.selectedVodId(); + return sourceId && Number.isFinite(contentId) && contentId > 0 + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); readonly providerOnly = computed(() => { this.routeParams(); return isProviderOnlyDetailState(window.history.state); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 01f1e87c7..4db6ae12e 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -65,12 +66,10 @@ describe('PortalInlinePlayerComponent sources picker', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); afterEach(() => { @@ -101,6 +100,7 @@ describe('PortalInlinePlayerComponent sources picker', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('playbackSessionKey', 'sources-key'); fixture.componentRef.setInput('playback', { streamUrl: 'https://example.com/movie.mkv', diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index dc6bcd277..7b09f18de 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -18,6 +18,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -41,12 +42,10 @@ describe('PortalInlinePlayerComponent up next rail', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); afterEach(() => { @@ -77,9 +76,11 @@ describe('PortalInlinePlayerComponent up next rail', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('playbackSessionKey', 'up-next-key'); } - const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail'); + const railEl = () => + fixture.nativeElement.querySelector('app-up-next-rail'); const seriesPlayback = { streamUrl: 'https://example.com/episode.mp4', diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 43d54185d..50f3235bf 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -84,6 +84,7 @@ >
} ({ @@ -19,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -27,7 +30,7 @@ class StubWebPlayerViewComponent { readonly seriesNavigation = input(null); readonly alternativeSources = input([]); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); readonly alternativeSourceRequested = output(); readonly playbackFailed = output(); readonly playbackEnded = output(); @@ -42,12 +45,10 @@ describe('PortalInlinePlayerComponent', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); beforeEach(async () => { @@ -66,6 +67,10 @@ describe('PortalInlinePlayerComponent', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); }); afterEach(() => { @@ -94,13 +99,17 @@ describe('PortalInlinePlayerComponent', () => { ( component as unknown as { playbackEnded: { subscribe: (fn: () => void) => void }; - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; nextEpisodeRequested: { subscribe: (fn: () => void) => void }; } ).playbackEnded.subscribe(() => events.push('ended')); ( component as unknown as { - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; } ).previousEpisodeRequested.subscribe(() => events.push('previous')); ( @@ -119,6 +128,7 @@ describe('PortalInlinePlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ).componentInstance as StubWebPlayerViewComponent; expect(webPlayer.seriesNavigation()).toBe(seriesNavigation); + expect(webPlayer.playbackSessionKey()).toBe('host-owned-session-key'); webPlayer.playbackEnded.emit(); webPlayer.previousEpisodeRequested.emit(); @@ -127,6 +137,48 @@ describe('PortalInlinePlayerComponent', () => { expect(events).toEqual(['ended', 'previous', 'next']); }); + it('forwards the exact resolved playback fallback request unchanged', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/live.m3u8', + title: 'Header-bearing live stream', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'live', + }, + }; + const request: PlaybackFallbackRequest = { + player: 'mpv', + playback, + diagnostic: { + code: 'network-error', + player: 'videojs', + source: 'hls', + container: '', + mimeType: '', + videoCodecs: [], + audioCodecs: [], + externalFallbackRecommended: true, + }, + }; + let forwarded: PlaybackFallbackRequest | undefined; + component.externalFallbackRequested.subscribe( + (value) => (forwarded = value) + ); + fixture.componentRef.setInput('playback', playback); + fixture.detectChanges(); + + const webPlayer = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + webPlayer.externalFallbackRequested.emit(request); + + expect(forwarded).toBe(request); + expect(forwarded?.playback).toBe(playback); + }); + describe('playerMediaTitle', () => { it('uses the series title with the episode label for episode playback', () => { fixture.componentRef.setInput('playback', { @@ -254,6 +306,10 @@ describe('PortalInlinePlayerComponent', () => { .compileComponents(); fixture = TestBed.createComponent(PortalInlinePlayerComponent); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); component = fixture.componentInstance; }); @@ -308,6 +364,10 @@ describe('PortalInlinePlayerComponent', () => { .compileComponents(); fixture = TestBed.createComponent(PortalInlinePlayerComponent); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); component = fixture.componentInstance; } diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 734efb70b..25437a2bc 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -63,6 +63,7 @@ const RAIL_STAGE_GAP = 18; }, }) export class PortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesNavigation = input(null); @@ -77,8 +78,7 @@ export class PortalInlinePlayerComponent { readonly title = computed(() => applyChannelNameStrip( this.playback()?.title, - this.playback()?.isLive && - this.settingsStore.stripCountryPrefix?.() + this.playback()?.isLive && this.settingsStore.stripCountryPrefix?.() ) ); readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); @@ -149,18 +149,15 @@ export class PortalInlinePlayerComponent { return { primary, secondary: metadata?.label ?? null }; }); - private readonly stageViewport = viewChild>( - 'stageViewport' - ); + private readonly stageViewport = + viewChild>('stageViewport'); /** * Border-box size of the theater stage, written by a ResizeObserver. * Measuring the border box (not the content box) keeps the value stable * when the rail modifier toggles the stage's own padding, so the gate * below cannot oscillate around its threshold. */ - readonly stageSize = signal<{ width: number; height: number } | null>( - null - ); + readonly stageSize = signal<{ width: number; height: number } | null>(null); /** * Width the rail would actually get: the stage minus its docked-mode * padding, the 16:9 player sized to the remaining height, and the flex diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index ec360df08..b142889e3 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -265,6 +265,7 @@ @if (inlinePlayback(); as playback) { { } = {}) => { fixture = TestBed.createComponent(VodDetailsComponent); fixture.componentRef.setInput('item', STALKER_VOD); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'stalker-host-owned-key' + ); fixture.componentRef.setInput('playbackPosition', playbackPosition); fixture.componentRef.setInput('externalPlayback', externalPlayback); fixture.componentRef.setInput('providerOnly', providerOnly); @@ -128,6 +133,24 @@ describe('VodDetailsComponent offline playback', () => { await fixture.whenStable(); }; + it('passes the host-owned session key unchanged to inline playback', async () => { + await render(); + fixture.componentRef.setInput('inlinePlayback', { + streamUrl: 'https://portal.example/movie.mp4', + title: 'Catalog Movie', + }); + fixture.detectChanges(); + + const inlinePlayer = fixture.debugElement.query( + By.css('app-portal-inline-player') + ).componentInstance as { + playbackSessionKey(): string; + }; + expect(inlinePlayer.playbackSessionKey()).toBe( + 'stalker-host-owned-key' + ); + }); + const completeDownload = ( fileAvailability: DownloadItem['fileAvailability'] = 'available' ) => { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 9f613b414..ab505e0b8 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -79,6 +79,7 @@ export class VodDetailsComponent { /** VOD item with discriminated union type */ readonly item = input.required(); + readonly playbackSessionKey = input.required(); /** Whether this item is in favorites (managed by parent) */ readonly isFavorite = input(false); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index d06a75c85..f451814db 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -96,6 +96,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { .compileComponents(); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -124,6 +125,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { fixture.destroy(); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 5d677add9..9225fe644 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -156,6 +156,7 @@ describe('WebPlayerViewComponent', () => { storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs })); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => { const pendingSettings = new Subject(); storageMap.get.mockReturnValue(pendingSettings.asObservable()); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index c5c7509da..24c907c8c 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -97,6 +97,7 @@ export class WebPlayerViewComponent implements OnDestroy { private readonly settingsStore = inject(SettingsStore); streamUrl = input.required(); + readonly playbackSessionKey = input.required(); title = input(''); playback = input(null); startTime = input(0); @@ -136,7 +137,10 @@ export class WebPlayerViewComponent implements OnDestroy { this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES) ); readonly hiddenAlternativeCount = computed(() => - Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES) + Math.max( + 0, + this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES + ) ); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); @@ -221,7 +225,9 @@ export class WebPlayerViewComponent implements OnDestroy { } return { primary: title, secondary: null }; }); - readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? ''); + readonly recordingFolder = computed( + () => this.settings()?.recordingFolder ?? '' + ); /** Stream URL the currently configured Electron header override belongs to. */ private headerScopeStreamUrl: string | null = null; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts index 34ae1087e..2d6e7345b 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts @@ -4,13 +4,17 @@ import { TranslateService } from '@ngx-translate/core'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util'; import { GlobalFavoritesService } from './global-favorites.service'; +import { of } from 'rxjs'; describe('GlobalFavoritesService', () => { let service: GlobalFavoritesService; let electronApi: { + dbGetAppState: jest.Mock; dbReorderGlobalFavorites: jest.Mock; dbSetAppState: jest.Mock; }; + const storeSelect = jest.fn(); + const getPlaylistById = jest.fn(); const makeChannel = ( overrides: Partial & @@ -25,7 +29,10 @@ describe('GlobalFavoritesService', () => { }); beforeEach(() => { + storeSelect.mockReset().mockReturnValue(of([])); + getPlaylistById.mockReset(); electronApi = { + dbGetAppState: jest.fn().mockResolvedValue(null), dbReorderGlobalFavorites: jest .fn() .mockResolvedValue({ success: true }), @@ -39,9 +46,12 @@ describe('GlobalFavoritesService', () => { TestBed.configureTestingModule({ providers: [ GlobalFavoritesService, - { provide: Store, useValue: { select: jest.fn() } }, + { provide: Store, useValue: { select: storeSelect } }, { provide: DatabaseService, useValue: {} }, - { provide: PlaylistsService, useValue: {} }, + { + provide: PlaylistsService, + useValue: { getPlaylistById }, + }, { provide: TranslateService, useValue: { instant: (key: string) => key }, @@ -58,6 +68,52 @@ describe('GlobalFavoritesService', () => { }); }); + it('keeps legacy URL UIDs aligned with the persisted M3U order', async () => { + const firstUrl = 'https://streams.example/one.ts'; + const secondUrl = 'https://streams.example/two.ts'; + storeSelect.mockReturnValue( + of([ + { + _id: 'playlist-one', + title: 'Playlist', + favorites: [firstUrl, secondUrl], + }, + ]) + ); + getPlaylistById.mockReturnValue( + of({ + playlist: { + items: [ + { + id: 'channel-one', + name: 'Channel One', + url: firstUrl, + }, + { + id: 'channel-two', + name: 'Channel Two', + url: secondUrl, + }, + ], + }, + }) + ); + electronApi.dbGetAppState.mockResolvedValue( + JSON.stringify([ + `m3u::playlist-one::${secondUrl}`, + `m3u::playlist-one::${firstUrl}`, + ]) + ); + + const channels = await service.getUnifiedLiveFavorites(); + + expect(channels.map((channel) => channel.uid)).toEqual([ + `m3u::playlist-one::${secondUrl}`, + `m3u::playlist-one::${firstUrl}`, + ]); + expect(channels.map((channel) => channel.position)).toEqual([0, 1]); + }); + describe('reorder', () => { it('sends playlist-scoped position updates for Xtream favorites', async () => { // The backend UPDATE filters by (contentId, playlistId); a payload @@ -109,7 +165,10 @@ describe('GlobalFavoritesService', () => { expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled(); expect(electronApi.dbSetAppState).toHaveBeenCalledWith( 'global-favorites-channel-order-v1', - JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5']) + JSON.stringify([ + 'm3u::playlist-m::url', + 'stalker::playlist-s::5', + ]) ); }); });