diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index bd9ff8012..8e77e6cc6 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -390,6 +390,12 @@ 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 classification happens BEFORE the handshake, not after: a **foreign-host** +static URL never needs the session at all, and warming it anyway would stall +playback behind a request worth up to 15 s against a portal that may be slow +or offline while the CDN is perfectly reachable — for a result that is then +discarded. + 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 diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 9114e59a1..71a15e4db 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -900,6 +900,40 @@ describe('StreamResolverService', () => { expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD'); }); + it('serves a foreign-host static stream without waiting on the portal', async () => { + // The CDN needs no portal credentials, so the handshake would be a + // discarded result — and a stall of up to 15 s when the portal is + // slow or offline but the CDN is fine. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial) + ); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::94', + name: 'Cdn Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '94', + stalkerCmd: 'ffrt3 https://cdn.example.com/live/94.m3u8', + stalkerItem: { + id: '94', + cmd: 'ffrt3 https://cdn.example.com/live/94.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(playback.streamUrl).toBe('https://cdn.example.com/live/94.m3u8'); + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + }); + it('still plays a static stream when the session cannot be established', async () => { // The static URL may point at a CDN that needs no credentials, so a // failed handshake must not cost the user their playback. 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 75cd9d5f9..e0dbcc354 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 @@ -385,23 +385,29 @@ 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. - const sessionUsable = await this.warmStalkerSession(playlist); - - // 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, { + // + // Classified BEFORE authenticating: a foreign-host stream never + // needs the session, and warming it anyway would stall playback + // behind a handshake against a portal that may be slow or offline + // while the CDN is perfectly reachable. + const servePlayback = () => + this.buildStalkerPlayback(item, playlist, { macAddress, portalUrl, streamUrl: staticUrl, isLive: item.radio === 'true' ? undefined : true, }); + + if (!isStalkerStreamCredentialSafe(portalUrl, staticUrl)) { + return servePlayback(); + } + + // Portal-owned 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 (await this.warmStalkerSession(playlist)) { + return servePlayback(); } } 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 b42492bb5..24b26864e 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 @@ -254,13 +254,11 @@ describe('stalker-player-request.utils', () => { ); }); - 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, - }); - + it('serves a foreign-host static url without contacting the portal', async () => { + // A CDN stream never needs the portal session, so it must not be + // pushed onto the request path — and must not wait on a handshake + // either: that is up to 15 s per request against a portal that may + // be offline while the CDN is reachable. const streamUrl = await fetchStalkerPlaybackLink(deps(), { playlist: { ...PLAYLIST, @@ -272,6 +270,7 @@ describe('stalker-player-request.utils', () => { }); expect(streamUrl).toBe('http://cdn.example/movies/1.mkv'); + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); @@ -289,6 +288,11 @@ describe('stalker-player-request.utils', () => { ...stale, portalUrl: 'http://demo.example/stalker_portal/server/load.php', } as PlaylistMeta; + // Portal-owned URL: only those reach the handshake now, since a + // foreign host never needs the session. + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: 'TOKEN-REPAIRED', + }); await fetchStalkerPlaybackLink( { @@ -303,7 +307,7 @@ describe('stalker-player-request.utils', () => { { playlist: stale, selectedContentType: 'itv', - cmd: 'ffrt3 http://cdn.example/live/42.m3u8', + cmd: 'ffrt3 http://demo.example/live/42.m3u8', linkFlags: { use_http_tmp_link: '0' }, } ); 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 db55b41e5..8739edd27 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 @@ -76,19 +76,13 @@ 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. - 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. + // Classify BEFORE authenticating. A stream on a foreign host never + // needs the portal session, and warming it anyway would block + // playback behind a handshake worth up to 15 s per request against + // a slow or offline portal (`stalker.events.ts`) for a result that + // is then discarded — the CDN is reachable even when the portal is + // not. if ( - sessionUsable || !isStalkerStreamCredentialSafe( options.playlist.portalUrl ?? '', staticUrl @@ -96,6 +90,16 @@ export async function fetchStalkerPlaybackLink( ) { return staticUrl; } + + // Portal-owned: it may be gated on the Bearer token, and minting + // the link used to be what established the session. Without a + // usable one, serving this would be serving a known 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 (await ensureStalkerSession(deps, options.playlist)) { + return staticUrl; + } } }