diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index f90f89fe6..cb5c1e146 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -358,7 +358,7 @@ Callers pass the row they resolved the `cmd` from: meant a proxied station played a URL the portal never intended to serve. - VOD and series — `selectedItem()`. - Downloads — the movie payload, through the optional `linkFlags` argument on - `fetchLinkToPlay()`. + `fetchLinkToPlay()`, but only for a credential-free URL (see below). - Favorites and Recently Viewed — `StreamResolverService.resolveStalker()` reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`). An item with no row snapshot gets no verdict, except radio, which keeps its @@ -368,21 +368,40 @@ Callers pass the row they resolved the `cmd` from: `create_link` was also the request that warmed the portal session, and tokens live in memory only (`StalkerSessionService.tokenCache`). Skipping it therefore -has to account for streams that are still gated on the Bearer token: +has to account for streams that are still gated on the Bearer token — and +"which routes are already warm" is not a question worth answering per route: +the global collection detail sets the playlist and the selected item straight +from a persisted row, with no catalog load in between, so a VOD opened from +Favorites reaches the store's playback path stone cold. -- **Portal routes** (ITV, VOD, series, radio) are structurally warm. An item - cannot be selected before its catalog has loaded, and every catalog load goes - through `executeStalkerRequest`, which authenticates. No extra work needed. -- **Collection routes** (global Favorites / Recently Viewed) are not. They read - the persisted row and can play on a cold start, without the portal ever - having been opened this session. `StreamResolverService` therefore calls - `StalkerSessionService.ensureToken()` before building a static playback — - handshake + `get_profile`, no link minted, and it validates the identity the - cached token was negotiated for, which the raw `getCachedToken()` used by the - header builder cannot. A simple portal returns `null` immediately. - The call is best-effort: a static URL may point at a CDN needing no - credentials at all, so a failed handshake degrades to the token-less header - set rather than costing the user their playback. +Every static return therefore warms first, through one primitive — +`ensureStalkerSession()` in `stalker-request.utils.ts`, wrapping +`StalkerSessionService.ensureToken()`: + +- `fetchStalkerPlaybackLink()` calls it before returning a static URL, which + covers ITV, VOD, radio and downloads at a single choke point. +- `StreamResolverService` calls it on its own static branch, which does not go + through that function. + +`ensureToken` performs handshake + `get_profile` with no link minted, and +validates the identity the cached token was negotiated for — which the raw +`getCachedToken()` the header builders use cannot. It is cheap where it is not +needed: a simple portal returns immediately and a warm cache with a matching +fingerprint resolves without a request. + +The call is best-effort: a static URL may point at a CDN needing no credentials +at all, so a failed handshake degrades to the token-less header set rather than +costing the user their playback. + +**Downloads are the exception, and cannot use this.** A download request cannot +carry portal credentials at all — the main-process stored-header allowlist is +`User-Agent` / `Origin` / `Referer` only +(`download-request-headers.ts`), with no `Cookie` or `Authorization`. So +`startStalkerVodDownload` offers the static shortcut only for a URL that needs +none: it classifies the candidate with `isStalkerStreamCredentialSafe()` and +withholds the row (forcing `create_link`) for anything portal-owned. A +same-host movie keeps using the minted URL, which carries its own access token; +a CDN-hosted one keeps the permanent URL that survives retry. ### Resolved links are never stored @@ -402,12 +421,13 @@ cache or replay the result. What each persisting path actually stores: The download row is the only place a resolved URL outlives the playback that produced it, because the main-process downloader needs a URL it can retry and -resume with. Honouring the flags shrinks the exposure: a movie whose row needs -no temporary link now yields a permanent URL that survives retry. A movie that -genuinely needs one still stores a link that is dead by the time retry runs. -Fixing that needs the `cmd` on the download row plus a re-resolution step -before retry/resume, which is a schema change and is deliberately out of scope -here. +resume with. Honouring the flags shrinks the exposure: an unflagged movie on a +host that needs no portal credentials now yields a permanent URL that survives +retry. A movie that needs a temporary link — or one on the portal host, which a +download cannot authenticate against — still stores a link that is dead by the +time retry runs. Fixing that needs the `cmd` on the download row plus a +re-resolution step before retry/resume, which is a schema change and is +deliberately out of scope here. ### `forced_storage` and `play_token` @@ -442,6 +462,8 @@ Revisit both only with a portal that demonstrably fails without them. Recently Viewed stores the `cmd`, never the stream URL. - `stream-resolver.service.spec.ts` — the collection route, plus the cold full-portal session warm-up and its best-effort degradation. +- `stalker-vod-download.spec.ts` — a CDN movie takes the static shortcut, a + same-host one keeps minting. - `stalker-playback-context.service.spec.ts` — headers only, query-insensitive key. - `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index ad475766b..1a36a7e1e 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -19,6 +19,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { buildStalkerExternalPlaybackHeaders, + ensureStalkerSession, executeStalkerRequest, getStalkerPortalOrigin, isCrossOriginStalkerStream, @@ -434,27 +435,21 @@ export class StreamResolverService { /** * Establish the portal session a static stream may still be gated on. - * - * Best-effort on purpose: the static URL can just as well point at a - * foreign CDN that needs no credentials, so a failed handshake must not - * cost the user their playback. It degrades to the token-less header set, - * which is exactly what this path produced before. + * Shares the single primitive with the store's playback path so the two + * routes cannot drift apart on when a session is required. */ private async warmStalkerSession( playlist: Playlist | undefined ): Promise { - if (!playlist) { - return; - } - - try { - await this.stalkerSession.ensureToken(playlist); - } catch (error) { - this.logger.warn( - 'Could not establish the Stalker session for a static stream', - error - ); - } + await ensureStalkerSession( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + this.logger + ); } /** diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts index d18c01f93..10ac7b3b5 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts @@ -22,7 +22,10 @@ describe('stalker-player-request.utils', () => { let dataService: { sendIpcEvent: jest.Mock, unknown[]>; }; - let stalkerSession: Pick; + let stalkerSession: Pick< + StalkerSessionService, + 'makeAuthenticatedRequest' | 'ensureToken' + >; beforeEach(() => { dataService = { @@ -30,6 +33,7 @@ describe('stalker-player-request.utils', () => { }; stalkerSession = { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; }); @@ -186,6 +190,61 @@ describe('stalker-player-request.utils', () => { stalkerSession: stalkerSession as StalkerSessionService, }); + it('establishes the session before returning a static url on a full portal', async () => { + // Minting the link used to be what authenticated. The global + // collection detail sets playlist + item straight from a + // persisted row, so a cold-start VOD would otherwise play a + // same-host gated stream with no Bearer token. + const fullPortal = { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta; + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: fullPortal, + selectedContentType: 'vod', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://demo.example/movies/1.mkv'); + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ _id: PLAYLIST._id }) + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('skips the handshake for a simple portal', async () => { + await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'itv', + cmd: 'ffrt3 http://cdn.example/live/42.m3u8', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + }); + + it('still returns the static url when the handshake fails', async () => { + // The static URL may need no credentials at all, so a portal + // that cannot be reached must not cost the user their playback. + ( + stalkerSession.ensureToken as jest.Mock + ).mockRejectedValue(new Error('portal down')); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: 'ffrt3 http://cdn.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://cdn.example/movies/1.mkv'); + }); + it('plays the static cmd of an unflagged row without asking the portal', async () => { const streamUrl = await fetchStalkerPlaybackLink(deps(), { playlist: PLAYLIST, diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts index c49f0411e..0074bd4e3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts @@ -13,6 +13,7 @@ import { } from './stalker-link-semantics.utils'; import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils'; import { + ensureStalkerSession, executeStalkerRequest, type StalkerPortalRepairApi, } from './stalker-request.utils'; @@ -67,6 +68,14 @@ export async function fetchStalkerPlaybackLink( options.cmd ); if (staticUrl) { + // Returning here skips the request that used to authenticate. + // Not every caller has a warm session: the global collection + // detail sets the playlist and the item straight from a persisted + // row, with no catalog load in between, so a VOD opened from + // Favorites on a cold start would play a same-host gated stream + // without a Bearer token. Warming at this single choke point + // covers ITV, VOD, radio and downloads alike. + await ensureStalkerSession(deps, options.playlist); return staticUrl; } } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index f07f4b622..50624eca8 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -38,6 +38,40 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { } as Playlist; } +/** + * Establishes the portal session WITHOUT issuing a content request. + * + * Needed wherever playback returns a URL without calling the portal: minting + * a link used to be what authenticated, and tokens live in memory only + * (`StalkerSessionService.tokenCache`), so a cold start would otherwise hand + * a same-host gated stream headers with no `Authorization`. `ensureToken` + * also validates the identity the cached token was negotiated for, which the + * raw `getCachedToken()` the header builders use cannot. + * + * Cheap where it is not needed: a simple portal returns immediately, and a + * warm cache with a matching fingerprint resolves without a request. + * + * Best-effort on purpose — a static URL may point at a CDN that needs no + * credentials, so a failed handshake must not cost the user their playback. + */ +export async function ensureStalkerSession( + deps: StalkerRequestDeps, + playlist: PlaylistMeta | undefined, + logger?: { warn(...args: unknown[]): void } +): Promise { + if (!playlist || !isFullStalkerPortalPlaylist(playlist)) { + return; + } + + try { + await deps.stalkerSession.ensureToken( + toStalkerSessionPlaylist(playlist) + ); + } catch (error) { + logger?.warn('Could not establish the Stalker session', error); + } +} + /** * Routes one Stalker request according to the playlist's portal mode: * full portals go through the authenticated session (handshake + Bearer diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts index 2611a6ab9..b0749ef81 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts @@ -121,7 +121,7 @@ describe('startStalkerVodDownload', () => { expect(snapshot).not.toHaveProperty('tmdbId'); }); - it('hands the movie row on so an unflagged one can yield a permanent URL', async () => { + it('hands the movie row on so an unflagged CDN movie yields a permanent URL', async () => { // The download row stores whatever URL comes back and retry replays // it, so a row that needs no temporary link must be recognised as // such — a 5 s link would survive only the first attempt. @@ -171,4 +171,55 @@ describe('startStalkerVodDownload', () => { }) ); }); + + it('keeps minting a link for an unflagged movie on the portal host', async () => { + // A download cannot carry portal credentials — the main-process + // stored-header allowlist is User-Agent/Origin/Referer only. So a + // same-host static URL, which the portal may gate on the mac cookie + // or Bearer token, must still go through create_link and use the + // minted URL's own access token. + const startDownload = jest.fn().mockResolvedValue({ success: true }); + const fetchLinkToPlay = jest + .fn() + .mockResolvedValue('https://stalker.example.test/tmp/42?tok=1'); + const data = { + id: '42', + cmd: 'ffrt3 https://stalker.example.test/movies/42.mkv', + use_http_tmp_link: '0', + use_load_balancing: '0', + info: { name: 'Same Host Movie' }, + }; + + await startStalkerVodDownload( + { + type: 'stalker', + playlistId: 'stalker-1', + cmd: data.cmd, + data, + } as unknown as VodDetailsItem, + { + playlist: { + id: 'stalker-1', + portalUrl: 'https://stalker.example.test/portal.php', + macAddress: '00:1A:79:12:34:56', + }, + downloadsService: { startDownload }, + fetchMovieFileId: jest.fn(), + fetchLinkToPlay, + } + ); + + // No row handed over ⇒ the static shortcut is not offered. + expect(fetchLinkToPlay).toHaveBeenCalledWith( + 'https://stalker.example.test/portal.php', + '00:1A:79:12:34:56', + data.cmd, + undefined + ); + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://stalker.example.test/tmp/42?tok=1', + }) + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts index e2f4c285d..e677d1d62 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -10,8 +10,10 @@ type StalkerVodDetailsItem = Extract; import { normalizeStalkerEntityId, normalizeStalkerEntityIdAsNumber, + resolveStalkerStaticPlaybackUrl, type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; +import { isStalkerStreamCredentialSafe } from '@iptvnator/shared/interfaces'; /** * Starting a download of a Stalker VOD item. @@ -132,11 +134,27 @@ export async function startStalkerVodDownload( // A movie whose row needs no temporary link yields a permanent URL, which // is what the download row stores — a 5 s link would only ever survive the // first attempt. + // + // But a download cannot authenticate the way playback can: the stored + // header allowlist in the main process is User-Agent / Origin / Referer + // only (`download-request-headers.ts`), with no Cookie or Authorization. + // So the static shortcut is offered only for a URL that needs no portal + // credentials — i.e. one the shared classifier says is NOT portal-owned. + // A same-host movie keeps going through `create_link`, whose minted URL + // carries its own access token. + const staticCandidate = resolveStalkerStaticPlaybackUrl( + itemData, + cmdToUse + ); + const staticUrlIsSelfAuthenticating = + staticCandidate !== null && + !isStalkerStreamCredentialSafe(playlist.portalUrl, staticCandidate); + const url = await deps.fetchLinkToPlay( playlist.portalUrl, playlist.macAddress, cmdToUse, - itemData + staticUrlIsSelfAuthenticating ? itemData : undefined ); if (!url) { return;