mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(playback): keep recovery keys credential-free
This commit is contained in:
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`.
|
||||
|
||||
|
||||
+108
-37
@@ -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('');
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+89
-3
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user