From 23ac120144242da0f048738bf7fd6a16e387729f Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 4 Aug 2026 18:17:21 +0200 Subject: [PATCH] fix(playback): keep recovery keys credential-free --- docs/architecture/embedded-inline-playback.md | 11 +- ...alker-episode-playback-session-key.spec.ts | 145 +++++++++++++----- .../stalker-episode-playback-session-key.ts | 7 +- ...r-series-view.playback-session-key.spec.ts | 92 ++++++++++- 4 files changed, 208 insertions(+), 47 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index ff4cd6820..5ce489beb 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -46,10 +46,13 @@ Every inline playback host owns a required, URL-independent 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. +also includes the explicit series mode, normalized parent, exact season key, +season number, and episode number; synthesized episode hashes are not identity. +The original provider command or episode ID exists only in the short-lived +pending-request guard used to locate the exact selected episode and reject +stale or out-of-order completion. It is never serialized into the recovery +session key. This keeps token refreshes in one logical session while ensuring +colliding tracking hashes cannot select a sibling episode. Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass the key unchanged to `WebPlayerViewComponent`. 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 index 9c393b7db..7328ee903 100644 --- 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 @@ -1,7 +1,11 @@ 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'; +import { + captureStalkerEpisodePlaybackSessionIdentity, + createStalkerEpisodePlaybackSessionKey, + resolveStalkerEpisodeStateByIdentity, +} from './stalker-episode-playback-session-key'; const episodeState = ( episode: Partial, @@ -25,57 +29,95 @@ 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', - ]); + ])( + 'keeps %s provider credentials out of the structural key', + (seriesMode) => { + const originalCmdA = + 'https://user:password@stream.example/episode.mpg?access_token=secret-a'; + const originalCmdB = + 'https://user:password@stream.example/episode.mpg?access_token=secret-b'; + const stateA = episodeState({ originalCmd: originalCmdA }); + const stateB = episodeState({ originalCmd: originalCmdB }); + const composite = JSON.stringify([ + seriesMode, + 'parent|series', + stateA.seasonKey, + stateA.seasonNumber, + stateA.episodeNumber, + ]); - expect( - createStalkerEpisodePlaybackSessionKey({ + const keyA = createStalkerEpisodePlaybackSessionKey({ sourceId: 'playlist|source', parentSeriesId: 'parent|series', seriesMode, - episodeState: state, - }) - ).toBe( - createPlaybackSessionKey({ - kind: 'episode', + episodeState: stateA, + }); + const keyB = createStalkerEpisodePlaybackSessionKey({ sourceId: 'playlist|source', - contentId: composite, - seriesId: 'parent|series', - seasonNumber: 1, - episodeNumber: 2, - }) - ); - }); + parentSeriesId: 'parent|series', + seriesMode, + episodeState: stateB, + }); - it('uses the complete provider episode id for lazy VOD episodes', () => { - const state = episodeState({ originalId: 'provider|episode:full' }); - const key = createStalkerEpisodePlaybackSessionKey({ + expect(keyA).toBe( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'playlist|source', + contentId: composite, + seriesId: 'parent|series', + seasonNumber: 1, + episodeNumber: 2, + }) + ); + expect(keyB).toBe(keyA); + expect(keyA).not.toContain(originalCmdA); + expect(keyA).not.toContain('password'); + expect(keyA).not.toContain('secret-a'); + } + ); + + it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => { + const stateA = episodeState({ originalId: 'provider|episode:token-a' }); + const stateB = episodeState({ originalId: 'provider|episode:token-b' }); + const options = { sourceId: 'playlist', parentSeriesId: '50001', seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, - episodeState: state, + } as const; + const identityA = captureStalkerEpisodePlaybackSessionIdentity({ + ...options, + episodeState: stateA, }); + const keyB = createStalkerEpisodePlaybackSessionKey({ + ...options, + episodeState: stateB, + }); + if (!identityA) throw new Error('Expected a captured request identity'); - expect(key).toContain('provider|episode:full'); - expect(key).not.toContain('synthesized-32-bit-id'); + expect(identityA.originalEpisodeIdentity).toBe( + 'provider|episode:token-a' + ); + expect(keyB).toBe(identityA.sessionKey); + expect(keyB).not.toContain('provider|episode:token-a'); + expect(keyB).not.toContain('provider|episode:token-b'); + expect(keyB).not.toContain('synthesized-32-bit-id'); + expect( + resolveStalkerEpisodeStateByIdentity({ + episodesBySeason: { [stateB.seasonKey]: [stateB.episode] }, + identity: identityA, + }) + ).toBeNull(); }); - it('separates parent, mode, season, command, and episode identity without delimiter collisions', () => { + it('separates every structural coordinate without delimiter collisions', () => { const base = { - sourceId: 'playlist', + sourceId: 'playlist|a', parentSeriesId: 'parent|a', seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, episodeState: episodeState({ originalCmd: 'cmd|a' }), } as const; const variants = [ + { ...base, sourceId: 'playlist|b' }, { ...base, parentSeriesId: 'parent|b' }, { ...base, @@ -88,7 +130,13 @@ describe('createStalkerEpisodePlaybackSessionKey', () => { 'season|2' ), }, - { ...base, episodeState: episodeState({ originalCmd: 'cmd|b' }) }, + { + ...base, + episodeState: { + ...base.episodeState, + seasonNumber: 2, + }, + }, { ...base, episodeState: { @@ -98,20 +146,43 @@ describe('createStalkerEpisodePlaybackSessionKey', () => { }, ]; const baseKey = createStalkerEpisodePlaybackSessionKey(base); + const delimiterVariantKey = createStalkerEpisodePlaybackSessionKey({ + ...base, + sourceId: 'playlist', + parentSeriesId: 'a|parent|a', + }); expect( variants.map(createStalkerEpisodePlaybackSessionKey) ).not.toContain(baseKey); + expect(delimiterVariantKey).not.toBe(baseKey); }); - it.each([episodeState({}), episodeState({ originalCmd: '' })])( + it.each([ + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + state: episodeState({}), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + state: episodeState({ originalCmd: '' }), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + state: episodeState({}), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + state: episodeState({ originalId: '' }), + }, + ])( 'fails closed when original episode identity is absent', - (state) => { + ({ seriesMode, state }) => { expect( createStalkerEpisodePlaybackSessionKey({ sourceId: 'playlist', parentSeriesId: 'series', - seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + seriesMode, 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 index fe706972d..894f417d1 100644 --- 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 @@ -67,18 +67,19 @@ export function captureStalkerEpisodePlaybackSessionIdentity({ return null; } - const contentId = JSON.stringify([ + // Provider commands and IDs remain transient request guards below. They + // may contain credentials and must never enter recovery ownership state. + const structuralContentId = JSON.stringify([ seriesMode, seriesId, seasonKey, seasonNumber, episodeNumber, - originalIdentity, ]); const sessionKey = createPlaybackSessionKey({ kind: 'episode', sourceId: normalizedSourceId, - contentId, + contentId: structuralContentId, seriesId, seasonNumber, episodeNumber, 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 index aaf0e0c0e..8d96a3be8 100644 --- 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 @@ -252,7 +252,7 @@ describe('StalkerSeriesViewComponent playback session key', () => { expect(fixture.componentInstance.playbackSessionKey()).toBe(key); }); - it('changes when the original full episode command changes', async () => { + it('preserves the logical key when the provider command rotates', async () => { await playFirstEpisode(); const firstKey = fixture.componentInstance.playbackSessionKey(); serialSeasons.set([ @@ -266,8 +266,94 @@ describe('StalkerSeriesViewComponent playback session key', () => { await playFirstEpisode(); - expect(fixture.componentInstance.playbackSessionKey()).not.toBe( - firstKey + expect(fixture.componentInstance.playbackSessionKey()).toBe(firstKey); + expect(firstKey).not.toContain('ffrt4://regular|full-command'); + expect(firstKey).not.toContain('ffrt4://different|full-command'); + }); + + it('rejects a pending command refresh but lets the refreshed episode reuse its logical key', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const pending = deferred(); + resolveVodPlayback.mockReturnValueOnce(pending.promise); + const [seasonKeyA, episodesA] = Object.entries( + fixture.componentInstance.mappedSeasons() + )[0]; + const episodeA = episodesA[0] as StalkerMappedEpisode; + const expectedKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: seasonKeyA, + seasonNumber: Number(episodeA.season) || Number(seasonKeyA), + episodeNumber: Number(episodeA.episode_num), + episode: episodeA, + previous: null, + next: null, + }, + }); + fixture.componentInstance.onEpisodeClicked(episodeA); + + serialSeasons.set([ + { + id: 'replacement-season', + name: 'Season 1', + cmd: 'ffrt4://refreshed|full-command', + series: [1, 2], + }, + ]); + fixture.detectChanges(); + const [seasonKeyB, episodesB] = Object.entries( + fixture.componentInstance.mappedSeasons() + )[0]; + const episodeB = episodesB[0] as StalkerMappedEpisode; + const refreshedKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: seasonKeyB, + seasonNumber: Number(episodeB.season) || Number(seasonKeyB), + episodeNumber: Number(episodeB.episode_num), + episode: episodeB, + previous: null, + next: episodesB[1] ?? null, + }, + }); + + expect(episodeB.originalCmd).not.toBe(episodeA.originalCmd); + expect(episodeB.id).not.toBe(episodeA.id); + expect(refreshedKey).toBe(expectedKey); + + pending.resolve({ + streamUrl: 'https://stale.example/old-command.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episodeA.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + + fixture.componentInstance.onEpisodeClicked(episodeB); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + 'ffrt4://refreshed|full-command', + 'Series', + 'poster.jpg', + 1, + Number(episodeB.id), + undefined + ); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + expectedKey ); });