fix(playback): keep recovery keys credential-free

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent a39a7953c2
commit 23ac120144
4 files changed
+208 -47

No files matched your search

@@ -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`.
@@ -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<StalkerMappedEpisode>,
@@ -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('');
@@ -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,
@@ -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<ResolvedPortalPlayback>();
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
);
});