mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
2adf687b9b
commit
0278205984
7 files changed
+171
-33
No files matched your search
@@ -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
|
||||
|
||||
+71
-20
@@ -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],
|
||||
[
|
||||
|
||||
+9
-13
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user