diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index b79e511a2..3b8737c8d 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index a2277cd54..b70d3b184 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -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, diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts index 621d28344..d5027eed6 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts @@ -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; } /** diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index cda2d7219..8ac4bbda2 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index aca6cd3c2..398228470 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 4424e480f..e42b528b9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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], [ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 9396950b0..0f2f392b4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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,