From 01d9785c3ec2e92bb464a316bc0b90e60addd32d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 10:21:17 +0200 Subject: [PATCH] fix(stalker): fall back to create_link when a portal-owned static url has no session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on #1364. `create_link` was also the request that could FAIL, and a failure is what triggers the lazy portal repair. A playlist still misclassified as token-free, or pointing at an unrepaired endpoint, used to self-heal on that failure and then play; the static path issues no request, so nothing fires and the stream just 401s. Its suggested remedy — routing a skipped warm-up through `repairPortal()` — cannot be taken literally: a skipped warm-up is the NORMAL case for the many legitimately token-free reseller panels, and probing each of them on every playback would cost far more than the round trip this PR removes. What is decidable without a request is whether we are about to serve a stream we already know will fail. `ensureStalkerSession` now reports whether the session can serve credentialed playback — true for a portal needing no token and for one holding a usable token, false for a full portal left without one — and both static call sites act on it: - foreign-host URL: served regardless, it never needed the session; - portal-owned URL with a usable session: served, as before; - portal-owned URL with no usable session: falls back to `create_link`, which mints a URL carrying its own token AND re-enters the only path that can observe a failure and repair. That covers the unrepaired-endpoint half exactly. The misclassified-as-simple half stays open by construction — no request means no evidence, and "simple portal" is indistinguishable from "misclassified" without one. It belongs with the other reactive-repair work already handed to PR 6: refresh and repair on an OBSERVED playback authorization failure. Co-Authored-By: Claude Opus 5 --- docs/architecture/stalker-portal.md | 19 ++++-- .../lib/collection/stream-resolver.service.ts | 29 ++++++--- .../stalker-player-request.utils.spec.ts | 61 +++++++++++++++++++ .../utils/stalker-player-request.utils.ts | 23 ++++++- .../lib/stores/utils/stalker-request.utils.ts | 19 ++++-- 5 files changed, 132 insertions(+), 19 deletions(-) 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; } }