diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 02fa6e477..4583ed9e8 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -389,9 +389,18 @@ validates the identity the cached token was negotiated for — which the raw 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. +The call is best-effort in one direction only. A **foreign-host** static URL +never needed the session, so a failed or skipped handshake still serves it. A +**portal-owned** one with no usable session would be served knowing it will +401, so both call sites fall back to `create_link` instead — which mints a URL +carrying its own token and, crucially, is the only path that can observe a +failure and trigger the lazy portal repair. That keeps a playlist still +misclassified as token-free, or pointing at an unrepaired endpoint, on the +self-healing path it was on before this change. + +`ensureStalkerSession` returns that verdict: `true` for a portal that needs no +token and for one holding a usable token, `false` for a full portal left +without one. **Known trade-off: a cached token is not revalidated.** `ensureToken` returns a same-identity cache entry without touching the network, so the static path no @@ -475,7 +484,9 @@ Revisit both only with a portal that demonstrably fails without them. `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. + the `series` exception, relative VOD commands, the session warm-up (simple + portal skipped, repaired endpoint used, failure degraded) and the + portal-owned-without-session fallback to `create_link`. - `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that Recently Viewed stores the `cmd`, never the stream URL. - `stream-resolver.service.spec.ts` — the collection route, plus the cold 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 21dec92d3..3f9f8509a 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 @@ -9,6 +9,7 @@ import { EpgItem, EpgProgram, Playlist, + isStalkerStreamCredentialSafe, ResolvedPortalPlayback, STALKER_REQUEST, StalkerPortalActions, @@ -378,14 +379,24 @@ export class StreamResolverService { // link; a simple portal returns null immediately. The raw row goes // in: the helper applies the repair override itself, exactly as // `executeStalkerRequest` does on the branch below. - await this.warmStalkerSession(playlist); + const sessionUsable = await this.warmStalkerSession(playlist); - return this.buildStalkerPlayback(item, playlist, { - macAddress, - portalUrl, - streamUrl: staticUrl, - isLive: item.radio === 'true' ? undefined : true, - }); + // A foreign-host stream never needed the session. A portal-owned + // one with no usable session would be served knowing it will 401, + // so fall through to `create_link` instead — it mints a URL that + // carries its own token and is the only path that can observe a + // failure and trigger the lazy portal repair. + if ( + sessionUsable || + !isStalkerStreamCredentialSafe(portalUrl, staticUrl) + ) { + return this.buildStalkerPlayback(item, playlist, { + macAddress, + portalUrl, + streamUrl: staticUrl, + isLive: item.radio === 'true' ? undefined : true, + }); + } } const contentType = item.radio === 'true' ? 'radio' : 'itv'; @@ -455,8 +466,8 @@ export class StreamResolverService { */ private async warmStalkerSession( playlist: Playlist | undefined - ): Promise { - await ensureStalkerSession( + ): Promise { + return ensureStalkerSession( { dataService: this.dataService, stalkerSession: this.stalkerSession, 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 e000df3cc..b42492bb5 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 @@ -199,6 +199,9 @@ describe('stalker-player-request.utils', () => { ...PLAYLIST, isFullStalkerPortal: true, } as PlaylistMeta; + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: 'TOKEN-WARM', + }); const streamUrl = await fetchStalkerPlaybackLink(deps(), { playlist: fullPortal, @@ -214,6 +217,64 @@ describe('stalker-player-request.utils', () => { expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); + it('falls back to create_link for a portal-owned url with no session', async () => { + // Serving a same-host static URL without a token means serving a + // known 401. The request path both mints a URL carrying its own + // token and is the only path that can observe a failure and + // trigger the lazy portal repair. + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: null, + }); + // A full portal dispatches through the authenticated session, not + // the raw IPC bridge. + ( + stalkerSession.makeAuthenticatedRequest as jest.Mock + ).mockResolvedValue({ + js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1'); + expect( + stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + }) + ); + }); + + it('serves a foreign-host static url even with no session', async () => { + // A CDN stream never needed the portal session, so a failed or + // skipped handshake must not push it onto the request path. + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: null, + }); + + 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'); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + it('handshakes against a repaired endpoint, not the stale one', async () => { // `executeStalkerRequest` applies the repair override on its first // line, so the static path must too — handshaking against the 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 0074bd4e3..db55b41e5 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 @@ -1,5 +1,6 @@ import { DataService } from '@iptvnator/services'; import { + isStalkerStreamCredentialSafe, PlaylistMeta, StalkerPortalActions, StalkerPortalItem, @@ -75,8 +76,26 @@ export async function fetchStalkerPlaybackLink( // 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; + const sessionUsable = await ensureStalkerSession( + deps, + options.playlist + ); + + // A stream on a foreign host never needed the session, so serve it + // regardless. A portal-owned one with no usable session would be + // served knowing it will 401 — fall back to the request path + // instead, which both mints a URL that carries its own token and + // is the only path that can observe a failure and trigger the + // lazy portal repair. + if ( + sessionUsable || + !isStalkerStreamCredentialSafe( + options.playlist.portalUrl ?? '', + staticUrl + ) + ) { + 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 84abdc06c..7659936f6 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 @@ -53,14 +53,21 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { * * 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. + * + * Returns whether the session is good enough to serve a stream that needs + * portal credentials: `true` for a portal that needs no token at all and for + * one that has a usable token, `false` for a full portal left without one. + * Callers use it to decide whether a portal-owned static URL can be trusted or + * whether they should fall back to the request path — which is also the path + * that can observe a failure and trigger the lazy repair. */ export async function ensureStalkerSession( deps: StalkerRequestDeps, playlist: PlaylistMeta | undefined, logger?: { warn(...args: unknown[]): void } -): Promise { +): Promise { if (!playlist) { - return; + return false; } // The repair override is applied here for the same reason @@ -72,16 +79,20 @@ export async function ensureStalkerSession( ? deps.portalRepair.applyOverride(playlist) : playlist; + // A token-free panel needs no session, so it can serve credentialed + // streams as well as it ever could. if (!isFullStalkerPortalPlaylist(effective)) { - return; + return true; } try { - await deps.stalkerSession.ensureToken( + const { token } = await deps.stalkerSession.ensureToken( toStalkerSessionPlaylist(effective) ); + return Boolean(token); } catch (error) { logger?.warn('Could not establish the Stalker session', error); + return false; } }