fix(stalker): keep temporary-link flags across VOD normalization

Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a
raw portal row to an explicit whitelist, and the two flags were not on it.
It feeds both `selectedItem()` — which the VOD playback path reads as
`linkFlags` — and, through `createStalkerVodItem`, the download payload. So a
flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took
the static path, playing the portal's non-final URL instead of minting a link.

The direction of the failure is what makes it a P1: a dropped flag reads as
"no temporary link needed", so the whitelist fails OPEN. Both flags now sit on
`StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the
consequence spelled out at the normalizer so the next edit does not quietly
undo it, and specs pinning all three normalizers plus a store-level test that
a flagged VOD still mints.

Also two things from re-reading my own diff:
- The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the
  same row to `fetchStalkerPlaybackLink`, which runs that exact check again.
  Two copies of one decision is the divergence this PR exists to remove, so
  the outer call and its now-unreachable guard are gone.
- `portal-catalog-facade.ts` spells the flag shape out instead of importing
  `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared`
  may not depend on `type:data-access`/`domain:stalker`), so the obvious
  "reuse the type" cleanup does not get made and break the boundary lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 09:20:23 +02:00
1 parent 2adf687b9b
commit 0278205984
7 files changed
+171 -33

No files matched your search

+14
View File
@@ -340,6 +340,17 @@ short-circuits before the request. It never applies it when `series` is set:
an episode is selected server-side by that parameter, so the parent row's
static `cmd` addresses the series, not the episode.
**The flags must survive normalization.** `buildStalkerSelectedVodItem()`
(`stalker-vod.utils.ts`) narrows a raw portal row to a whitelist, so a field it
does not name is silently dropped — and it feeds both VOD playback
(`selectedItem()`) and the download payload (`createStalkerVodItem` passes its
`data` straight through). Losing a flag there fails **open**: the row reads as
"no temporary link needed" and the static path plays the portal's non-final
URL. Both flags are therefore on the whitelist, on `StalkerVodSource` /
`StalkerSelectedVodItem`, and pinned by tests in `stalker-vod.utils.spec.ts`.
Any new normalizer between a portal response and a playback call has the same
obligation.
Callers pass the row they resolved the `cmd` from:
- ITV and radio — the channel/station row (`withStalkerPlayer`); radio
@@ -402,6 +413,9 @@ Revisit both only with a portal that demonstrably fails without them.
### Regression coverage
- `stalker-link-semantics.utils.spec.ts` — the decision table above.
- `stalker-vod.utils.spec.ts` — both flags survive
`buildStalkerSelectedVodItem` / `normalizeStalkerVodDetailsItem` /
`normalizeStalkerFavoriteItem`, and an unflagged row gains no flags.
- `stalker-player-request.utils.spec.ts` — static short-circuit, both flags,
the `series` exception, relative VOD commands.
- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that
@@ -91,6 +91,11 @@ export interface StalkerPortalCatalogFacade<
* `linkFlags` carries the catalog row's `use_http_tmp_link` /
* `use_load_balancing`; without it the portal is always asked for a
* temporary link.
*
* The shape is spelled out rather than imported as `StalkerLinkFlagSource`
* on purpose: this lib is `type:util`/`domain:portal-shared` and may not
* depend on `portal-stalker-data-access` (`type:data-access`/`domain:stalker`)
* — the Nx module-boundary rule rejects that edge.
*/
fetchLinkToPlay(
portalUrl: string,
@@ -17,6 +17,13 @@ export interface StalkerVodSource {
cmd?: string;
series?: unknown[];
has_files?: number;
/**
* Temporary-link flags the portal sets on a row. Carried through every
* normalizer because playback and downloads read them to decide whether
* `create_link` is required — dropping one fails open.
*/
use_http_tmp_link?: unknown;
use_load_balancing?: unknown;
is_series?: boolean | number | string | null | undefined;
video_id?: string;
category_id?: string;
@@ -47,6 +54,9 @@ export interface StalkerSelectedVodItem extends StalkerVodDetails {
is_series?: true;
video_id?: string;
category_id?: string;
/** See {@link StalkerVodSource.use_http_tmp_link}. */
use_http_tmp_link?: unknown;
use_load_balancing?: unknown;
}
/**
@@ -2,12 +2,14 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces';
import { StalkerFavoriteItem } from './models';
import {
buildStalkerFavoritePayload,
buildStalkerSelectedVodItem,
createStalkerInfo,
createStalkerInlineDetailState,
createStalkerDetailViewState,
isStalkerSeriesFlag,
normalizeStalkerFavoriteItem,
normalizeStalkerSeriesFlag,
normalizeStalkerVodDetailsItem,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
@@ -286,4 +288,52 @@ describe('stalker-vod.utils regressions', () => {
expect(info.tmdb_trailer).toBe('abc123def');
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
});
describe('temporary-link flags survive normalization', () => {
// `buildStalkerSelectedVodItem` is a whitelist, so a field it forgets
// is silently gone. For these two that fails OPEN: playback and
// downloads would read "no temporary link needed" and play the
// portal's non-final URL instead of minting one.
const FLAGGED_ROW = {
id: '42',
cmd: 'ffrt3 http://cdn.example/movie.mkv',
use_http_tmp_link: '1',
use_load_balancing: '0',
info: { name: 'Flagged Movie' },
};
it('keeps both flags on the selected VOD item', () => {
const selected = buildStalkerSelectedVodItem(FLAGGED_ROW);
expect(selected.use_http_tmp_link).toBe('1');
expect(selected.use_load_balancing).toBe('0');
});
it('keeps both flags through the VOD details normalizer', () => {
const normalized = normalizeStalkerVodDetailsItem(FLAGGED_ROW);
expect(normalized.use_http_tmp_link).toBe('1');
expect(normalized.use_load_balancing).toBe('0');
});
it('keeps both flags on a normalized favorite', () => {
const normalized = normalizeStalkerFavoriteItem(
FLAGGED_ROW as StalkerFavoriteItem
);
expect(normalized.details.use_http_tmp_link).toBe('1');
expect(normalized.details.use_load_balancing).toBe('0');
});
it('leaves an unflagged row without inventing flags', () => {
const selected = buildStalkerSelectedVodItem({
id: '43',
cmd: 'ffrt3 http://cdn.example/other.mkv',
info: { name: 'Plain Movie' },
});
expect(selected.use_http_tmp_link).toBeUndefined();
expect(selected.use_load_balancing).toBeUndefined();
});
});
});
@@ -160,6 +160,16 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
};
}
/**
* Narrows a raw portal row to the fields the detail/playback flows need.
*
* This is a whitelist, not a spread, so anything not named here is dropped.
* `use_http_tmp_link` / `use_load_balancing` MUST stay on the list: playback
* and downloads read them to decide whether `create_link` is required, and a
* missing flag reads as "no temporary link needed" — it fails OPEN, playing
* the portal's non-final URL instead of minting one. See
* `docs/architecture/stalker-portal.md`, "Playback Link Resolution".
*/
export function buildStalkerSelectedVodItem(
item: StalkerVodSource,
forceSeries = false
@@ -169,6 +179,8 @@ export function buildStalkerSelectedVodItem(
cmd: toStringOrFallback(item.cmd),
series: item.series,
has_files: item.has_files,
use_http_tmp_link: item.use_http_tmp_link,
use_load_balancing: item.use_load_balancing,
is_series:
forceSeries || isStalkerSeriesFlag(item?.is_series)
? true
@@ -31,6 +31,20 @@ const PLAYLIST = {
isFullStalkerPortal: false,
} as PlaylistMeta;
interface SelectedTestItem {
id: string;
cmd: string;
has_files?: boolean;
title?: string;
name?: string;
o_name?: string;
logo?: string;
category_id?: string;
cover?: string;
use_http_tmp_link?: unknown;
use_load_balancing?: unknown;
}
const TestPlayerStore = signalStore(
withState({
currentPlaylist: PLAYLIST,
@@ -41,31 +55,13 @@ const TestPlayerStore = signalStore(
has_files: true,
title: 'Original Title',
category_id: 'vod',
} as {
id: string;
cmd: string;
has_files?: boolean;
title?: string;
name?: string;
o_name?: string;
logo?: string;
category_id?: string;
cover?: string;
},
} as SelectedTestItem,
}),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
patchState(store, { selectedContentType: type });
},
setSelectedItem(item: {
id: string;
cmd: string;
title?: string;
name?: string;
o_name?: string;
logo?: string;
category_id?: string;
}) {
setSelectedItem(item: SelectedTestItem) {
patchState(store, { selectedItem: item });
},
})),
@@ -425,6 +421,61 @@ describe('withStalkerPlayer', () => {
expect(playback.streamUrl).toBe('http://cdn.example/tmp/jazz.mp3');
});
it('mints a link for a flagged VOD row with a directly playable cmd', async () => {
// The VOD row reaches this method through
// `buildStalkerSelectedVodItem`, a whitelist — if it ever drops
// the flags again, this row reads as unflagged and the static
// path silently plays the portal's non-final URL.
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '42',
cmd: 'ffrt3 http://cdn.example/movie.mkv',
title: 'Flagged Movie',
category_id: 'vod',
use_http_tmp_link: '1',
});
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'http://cdn.example/tmp/movie.mkv?tok=1' },
});
const playback = await store.resolveVodPlayback(
undefined,
'Flagged Movie'
);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.CreateLink,
}),
})
);
expect(playback.streamUrl).toBe(
'http://cdn.example/tmp/movie.mkv?tok=1'
);
});
it('plays an unflagged VOD row from its static cmd', async () => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '43',
cmd: 'ffrt3 http://cdn.example/movie.mkv',
title: 'Static Movie',
category_id: 'vod',
use_http_tmp_link: '0',
use_load_balancing: '0',
});
const playback = await store.resolveVodPlayback(
undefined,
'Static Movie'
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playback.streamUrl).toBe('http://cdn.example/movie.mkv');
});
it.each([
['static', { use_http_tmp_link: '0' }, undefined],
[
@@ -32,7 +32,6 @@ import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
resolveStalkerStaticPlaybackUrl,
shouldResolveMovieFileId,
type StalkerLinkFlagSource,
} from '../utils';
@@ -322,24 +321,21 @@ export function withStalkerPlayer() {
throw new Error('nothing_to_play');
}
// Radio already skipped `create_link` for a directly
// playable command; going through the shared decision adds
// the flags, so a station the portal proxies now gets its
// temporary link instead of a dead static URL.
const streamUrl =
resolveStalkerStaticPlaybackUrl(item, item.cmd) ??
(await fetchStalkerPlaybackLink(requestDeps, {
// Radio used to skip `create_link` for any directly
// playable command. Handing the row to the shared decision
// adds the flags, so a station the portal proxies now gets
// its temporary link instead of a dead static URL.
const streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'radio',
linkFlags: item,
}));
if (!streamUrl) {
throw new Error('nothing_to_play');
}
}
);
recordRecentlyViewed(
item,