diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 3b8737c8d..f90f89fe6 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -364,6 +364,26 @@ Callers pass the row they resolved the `cmd` from: An item with no row snapshot gets no verdict, except radio, which keeps its long-standing "directly usable command plays as-is" behaviour. +### The static path still needs the session + +`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: + +- **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. + ### Resolved links are never stored A temporary link lives about 5 seconds (`tv_tmp_link_ttl` / @@ -420,7 +440,8 @@ Revisit both only with a portal that demonstrably fails without them. the `series` exception, relative VOD commands. - `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. +- `stream-resolver.service.spec.ts` — the collection route, plus the cold + full-portal session warm-up and its best-effort degradation. - `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.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index cb1766263..65fb9f7ca 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 @@ -45,6 +45,7 @@ describe('StreamResolverService', () => { stalkerSession = { getCachedToken: jest.fn(() => null), makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; epgBridge = { getChannelPrograms: jest.fn(), @@ -853,6 +854,86 @@ describe('StreamResolverService', () => { expect(playback.isLive).toBe(true); }); + it('authenticates a cold full-portal session before a static stream', async () => { + // Skipping create_link also skips the request that used to warm the + // session. Tokens are in-memory only, so a cold start from global + // Favorites would otherwise hand a same-host gated stream headers + // with no Authorization and take a 403. + 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) + ); + // Cold: no token until ensureToken has run. + stalkerSession.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockImplementation(async () => { + stalkerSession.getCachedToken.mockReturnValue('TOKEN-COLD'); + return { token: 'TOKEN-COLD' }; + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::91', + name: 'Cold Static Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '91', + stalkerCmd: 'ffrt3 https://stalker.example.com/live/91.m3u8', + stalkerItem: { + id: '91', + cmd: 'ffrt3 https://stalker.example.com/live/91.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'stalker-1' }) + ); + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/live/91.m3u8' + ); + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD'); + }); + + 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. + 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) + ); + stalkerSession.ensureToken.mockRejectedValue(new Error('portal down')); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::92', + name: 'Cdn Static Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '92', + stalkerCmd: 'ffrt3 https://cdn.example.com/live/92.m3u8', + stalkerItem: { + id: '92', + cmd: 'ffrt3 https://cdn.example.com/live/92.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(playback.streamUrl).toBe('https://cdn.example.com/live/92.m3u8'); + expect(playback.headers?.['Authorization']).toBeUndefined(); + }); + it('mints a link for a flagged Stalker favorite', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ 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 9e5107a15..ad475766b 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 @@ -28,7 +28,10 @@ import { StalkerSessionService, type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; -import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { + UnifiedCollectionItem, + createLogger, +} from '@iptvnator/portal/shared/util'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -75,6 +78,7 @@ export class StreamResolverService { private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly stalkerSession = inject(StalkerSessionService); private readonly portalRepair = inject(StalkerPortalRepairService); + private readonly logger = createLogger('StreamResolver'); private readonly m3uEpgTimeoutMs = 3000; private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); @@ -349,6 +353,17 @@ export class StreamResolverService { item.stalkerCmd ?? '' ); if (staticUrl) { + // Skipping `create_link` also skips the only authenticated + // request this route used to make, and it was what warmed the + // session. Tokens live in memory only, so on a cold start from + // global Favorites/Recently Viewed a same-host stream gated on + // the portal Bearer token would get headers without one and 403. + // `ensureToken` performs the handshake + get_profile — and + // validates the identity the cached token was negotiated for, + // which the raw `getCachedToken` below cannot — without minting a + // link; a simple portal returns null immediately. + await this.warmStalkerSession(playlist); + return this.buildStalkerPlayback(item, playlist, { macAddress, portalUrl, @@ -417,6 +432,31 @@ 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. + */ + 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 + ); + } + } + /** * The collection routes must hand players the SAME portal header set the * Stalker live layout builds — an auth-gated stream opened from Favorites