diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 8e77e6cc6..b7cf1fade 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -396,10 +396,10 @@ 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 -401, so both call sites fall back to `create_link` instead — which mints a URL +A **foreign-host** static URL is returned before the handshake is even +attempted — it never needed the session. A **portal-owned** one with no usable +session (handshake failed, or threw) 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 @@ -407,7 +407,9 @@ 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. +without one. It swallows a throwing handshake rather than propagating it — an +unreachable portal must not surface as an exception mid-playback — which +simply makes the verdict `false` and routes the row to `create_link`. **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 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 71a15e4db..1946aaa04 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 @@ -934,9 +934,11 @@ describe('StreamResolverService', () => { 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. + it('falls back to create_link when the handshake throws', async () => { + // A throwing handshake must not propagate. It leaves the session + // unusable, so a PORTAL-OWNED url goes to `create_link` rather than + // being served as a known 401 — the url has to be portal-owned for + // the handshake to run at all now that classification comes first. playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'stalker-1', @@ -947,25 +949,30 @@ describe('StreamResolverService', () => { } satisfies Partial) ); stalkerSession.ensureToken.mockRejectedValue(new Error('portal down')); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'https://stalker.example.com/tmp/92.ts?tok=1' }, + }); const playback = await service.resolvePlayback({ uid: 'stalker::stalker-1::92', - name: 'Cdn Static Channel', + name: 'Portal Static Channel', contentType: 'live', sourceType: 'stalker', playlistId: 'stalker-1', playlistName: 'Stalker', stalkerId: '92', - stalkerCmd: 'ffrt3 https://cdn.example.com/live/92.m3u8', + stalkerCmd: 'ffrt3 https://stalker.example.com/live/92.m3u8', stalkerItem: { id: '92', - cmd: 'ffrt3 https://cdn.example.com/live/92.m3u8', + cmd: 'ffrt3 https://stalker.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(); + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/tmp/92.ts?tok=1' + ); }); it('prefers the edited playlist coordinates over a stale favorite snapshot', async () => { 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 24b26864e..021332c5b 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 @@ -328,12 +328,19 @@ describe('stalker-player-request.utils', () => { 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. + it('falls back to create_link when the handshake throws', async () => { + // A throwing handshake must not propagate — it leaves the session + // unusable, and a portal-owned URL then goes to `create_link` + // rather than being served as a known 401. The command has to be + // portal-owned for the handshake to run at all. ( stalkerSession.ensureToken as jest.Mock ).mockRejectedValue(new Error('portal down')); + ( + stalkerSession.makeAuthenticatedRequest as jest.Mock + ).mockResolvedValue({ + js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' }, + }); const streamUrl = await fetchStalkerPlaybackLink(deps(), { playlist: { @@ -341,11 +348,12 @@ describe('stalker-player-request.utils', () => { isFullStalkerPortal: true, } as PlaylistMeta, selectedContentType: 'vod', - cmd: 'ffrt3 http://cdn.example/movies/1.mkv', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', linkFlags: { use_http_tmp_link: '0' }, }); - expect(streamUrl).toBe('http://cdn.example/movies/1.mkv'); + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1'); }); it('plays the static cmd of an unflagged row without asking the portal', async () => {