fix(stalker): warm the session at the choke point; keep downloads authenticated

Two more Codex findings on #1364, and the first one shows my previous commit
message reasoned too broadly.

P1 — I claimed the portal routes are "structurally warm" because an item
cannot be selected before its catalog loads. That is true of the routed portal
views, but not of the global collection detail, which calls
`setCurrentPlaylist()` and `setSelectedItem()` straight from a persisted row
with no catalog load in between and then goes through the STORE playback path.
A VOD opened from Favorites on a cold start therefore still played a same-host
gated stream with no Bearer token.

Rather than extend the per-route argument, the warm-up moved to the one place
every static return passes through: `fetchStalkerPlaybackLink()` now calls the
session before short-circuiting, covering ITV, VOD, radio and downloads at
once. `StreamResolverService` keeps its own call — its static branch does not
go through that function — but both now share a single primitive,
`ensureStalkerSession()` in `stalker-request.utils.ts`, so the two routes
cannot drift on when a session is required. Still best-effort, still outside
`stalker-session.service.ts` (PR 6 territory).

P2 — downloads cannot use that escape hatch at all: the main-process stored
header allowlist is User-Agent/Origin/Referer only, no Cookie or
Authorization, so a static same-host URL 401s where a minted one worked.
`startStalkerVodDownload` now classifies the candidate with the shared
`isStalkerStreamCredentialSafe()` and withholds the row — forcing
`create_link` — for anything portal-owned. A CDN-hosted movie keeps the
permanent URL that survives retry; a portal-hosted one keeps the minted URL
that carries its own token.

Both fixes mutation-checked: each reverted change fails exactly one test.
Docs corrected, including the overreaching "structurally warm" claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 09:45:03 +02:00
1 parent 409d8235a2
commit edc6952b58
7 files changed
+229 -41

No files matched your search

+43 -21
View File
@@ -358,7 +358,7 @@ Callers pass the row they resolved the `cmd` from:
meant a proxied station played a URL the portal never intended to serve.
- VOD and series — `selectedItem()`.
- Downloads — the movie payload, through the optional `linkFlags` argument on
`fetchLinkToPlay()`.
`fetchLinkToPlay()`, but only for a credential-free URL (see below).
- Favorites and Recently Viewed — `StreamResolverService.resolveStalker()`
reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`).
An item with no row snapshot gets no verdict, except radio, which keeps its
@@ -368,21 +368,40 @@ Callers pass the row they resolved the `cmd` from:
`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:
has to account for streams that are still gated on the Bearer token — and
"which routes are already warm" is not a question worth answering per route:
the global collection detail sets the playlist and the selected item straight
from a persisted row, with no catalog load in between, so a VOD opened from
Favorites reaches the store's playback path stone cold.
- **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.
Every static return therefore warms first, through one primitive —
`ensureStalkerSession()` in `stalker-request.utils.ts`, wrapping
`StalkerSessionService.ensureToken()`:
- `fetchStalkerPlaybackLink()` calls it before returning a static URL, which
covers ITV, VOD, radio and downloads at a single choke point.
- `StreamResolverService` calls it on its own static branch, which does not go
through that function.
`ensureToken` performs handshake + `get_profile` with no link minted, and
validates the identity the cached token was negotiated for — which the raw
`getCachedToken()` the header builders use cannot. It is cheap where it is not
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.
**Downloads are the exception, and cannot use this.** A download request cannot
carry portal credentials at all — the main-process stored-header allowlist is
`User-Agent` / `Origin` / `Referer` only
(`download-request-headers.ts`), with no `Cookie` or `Authorization`. So
`startStalkerVodDownload` offers the static shortcut only for a URL that needs
none: it classifies the candidate with `isStalkerStreamCredentialSafe()` and
withholds the row (forcing `create_link`) for anything portal-owned. A
same-host movie keeps using the minted URL, which carries its own access token;
a CDN-hosted one keeps the permanent URL that survives retry.
### Resolved links are never stored
@@ -402,12 +421,13 @@ cache or replay the result. What each persisting path actually stores:
The download row is the only place a resolved URL outlives the playback that
produced it, because the main-process downloader needs a URL it can retry and
resume with. Honouring the flags shrinks the exposure: a movie whose row needs
no temporary link now yields a permanent URL that survives retry. A movie that
genuinely needs one still stores a link that is dead by the time retry runs.
Fixing that needs the `cmd` on the download row plus a re-resolution step
before retry/resume, which is a schema change and is deliberately out of scope
here.
resume with. Honouring the flags shrinks the exposure: an unflagged movie on a
host that needs no portal credentials now yields a permanent URL that survives
retry. A movie that needs a temporary link — or one on the portal host, which a
download cannot authenticate against — still stores a link that is dead by the
time retry runs. Fixing that needs the `cmd` on the download row plus a
re-resolution step before retry/resume, which is a schema change and is
deliberately out of scope here.
### `forced_storage` and `play_token`
@@ -442,6 +462,8 @@ Revisit both only with a portal that demonstrably fails without them.
Recently Viewed stores the `cmd`, never the stream URL.
- `stream-resolver.service.spec.ts` — the collection route, plus the cold
full-portal session warm-up and its best-effort degradation.
- `stalker-vod-download.spec.ts` — a CDN movie takes the static shortcut, a
same-host one keeps minting.
- `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
@@ -19,6 +19,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import {
buildStalkerExternalPlaybackHeaders,
ensureStalkerSession,
executeStalkerRequest,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
@@ -434,27 +435,21 @@ 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.
* Shares the single primitive with the store's playback path so the two
* routes cannot drift apart on when a session is required.
*/
private async warmStalkerSession(
playlist: Playlist | undefined
): Promise<void> {
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
);
}
await ensureStalkerSession(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
this.logger
);
}
/**
@@ -22,7 +22,10 @@ describe('stalker-player-request.utils', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSession: Pick<StalkerSessionService, 'makeAuthenticatedRequest'>;
let stalkerSession: Pick<
StalkerSessionService,
'makeAuthenticatedRequest' | 'ensureToken'
>;
beforeEach(() => {
dataService = {
@@ -30,6 +33,7 @@ describe('stalker-player-request.utils', () => {
};
stalkerSession = {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
});
@@ -186,6 +190,61 @@ describe('stalker-player-request.utils', () => {
stalkerSession: stalkerSession as StalkerSessionService,
});
it('establishes the session before returning a static url on a full portal', async () => {
// Minting the link used to be what authenticated. The global
// collection detail sets playlist + item straight from a
// persisted row, so a cold-start VOD would otherwise play a
// same-host gated stream with no Bearer token.
const fullPortal = {
...PLAYLIST,
isFullStalkerPortal: true,
} as PlaylistMeta;
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: fullPortal,
selectedContentType: 'vod',
cmd: 'ffrt3 http://demo.example/movies/1.mkv',
linkFlags: { use_http_tmp_link: '0' },
});
expect(streamUrl).toBe('http://demo.example/movies/1.mkv');
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({ _id: PLAYLIST._id })
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('skips the handshake for a simple portal', async () => {
await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
selectedContentType: 'itv',
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
linkFlags: { use_http_tmp_link: '0' },
});
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.
(
stalkerSession.ensureToken as jest.Mock
).mockRejectedValue(new Error('portal down'));
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');
});
it('plays the static cmd of an unflagged row without asking the portal', async () => {
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
playlist: PLAYLIST,
@@ -13,6 +13,7 @@ import {
} from './stalker-link-semantics.utils';
import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils';
import {
ensureStalkerSession,
executeStalkerRequest,
type StalkerPortalRepairApi,
} from './stalker-request.utils';
@@ -67,6 +68,14 @@ export async function fetchStalkerPlaybackLink(
options.cmd
);
if (staticUrl) {
// Returning here skips the request that used to authenticate.
// Not every caller has a warm session: the global collection
// detail sets the playlist and the item straight from a persisted
// row, with no catalog load in between, so a VOD opened from
// 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;
}
}
@@ -38,6 +38,40 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
} as Playlist;
}
/**
* Establishes the portal session WITHOUT issuing a content request.
*
* Needed wherever playback returns a URL without calling the portal: minting
* a link used to be what authenticated, and tokens live in memory only
* (`StalkerSessionService.tokenCache`), so a cold start would otherwise hand
* a same-host gated stream headers with no `Authorization`. `ensureToken`
* also validates the identity the cached token was negotiated for, which the
* raw `getCachedToken()` the header builders use cannot.
*
* Cheap where it is not needed: a simple portal returns immediately, and a
* warm cache with a matching fingerprint resolves without a request.
*
* 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.
*/
export async function ensureStalkerSession(
deps: StalkerRequestDeps,
playlist: PlaylistMeta | undefined,
logger?: { warn(...args: unknown[]): void }
): Promise<void> {
if (!playlist || !isFullStalkerPortalPlaylist(playlist)) {
return;
}
try {
await deps.stalkerSession.ensureToken(
toStalkerSessionPlaylist(playlist)
);
} catch (error) {
logger?.warn('Could not establish the Stalker session', error);
}
}
/**
* Routes one Stalker request according to the playlist's portal mode:
* full portals go through the authenticated session (handshake + Bearer
@@ -121,7 +121,7 @@ describe('startStalkerVodDownload', () => {
expect(snapshot).not.toHaveProperty('tmdbId');
});
it('hands the movie row on so an unflagged one can yield a permanent URL', async () => {
it('hands the movie row on so an unflagged CDN movie yields a permanent URL', async () => {
// The download row stores whatever URL comes back and retry replays
// it, so a row that needs no temporary link must be recognised as
// such — a 5 s link would survive only the first attempt.
@@ -171,4 +171,55 @@ describe('startStalkerVodDownload', () => {
})
);
});
it('keeps minting a link for an unflagged movie on the portal host', async () => {
// A download cannot carry portal credentials — the main-process
// stored-header allowlist is User-Agent/Origin/Referer only. So a
// same-host static URL, which the portal may gate on the mac cookie
// or Bearer token, must still go through create_link and use the
// minted URL's own access token.
const startDownload = jest.fn().mockResolvedValue({ success: true });
const fetchLinkToPlay = jest
.fn()
.mockResolvedValue('https://stalker.example.test/tmp/42?tok=1');
const data = {
id: '42',
cmd: 'ffrt3 https://stalker.example.test/movies/42.mkv',
use_http_tmp_link: '0',
use_load_balancing: '0',
info: { name: 'Same Host Movie' },
};
await startStalkerVodDownload(
{
type: 'stalker',
playlistId: 'stalker-1',
cmd: data.cmd,
data,
} as unknown as VodDetailsItem,
{
playlist: {
id: 'stalker-1',
portalUrl: 'https://stalker.example.test/portal.php',
macAddress: '00:1A:79:12:34:56',
},
downloadsService: { startDownload },
fetchMovieFileId: jest.fn(),
fetchLinkToPlay,
}
);
// No row handed over ⇒ the static shortcut is not offered.
expect(fetchLinkToPlay).toHaveBeenCalledWith(
'https://stalker.example.test/portal.php',
'00:1A:79:12:34:56',
data.cmd,
undefined
);
expect(startDownload).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://stalker.example.test/tmp/42?tok=1',
})
);
});
});
@@ -10,8 +10,10 @@ type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
resolveStalkerStaticPlaybackUrl,
type StalkerLinkFlagSource,
} from '@iptvnator/portal/stalker/data-access';
import { isStalkerStreamCredentialSafe } from '@iptvnator/shared/interfaces';
/**
* Starting a download of a Stalker VOD item.
@@ -132,11 +134,27 @@ export async function startStalkerVodDownload(
// A movie whose row needs no temporary link yields a permanent URL, which
// is what the download row stores — a 5 s link would only ever survive the
// first attempt.
//
// But a download cannot authenticate the way playback can: the stored
// header allowlist in the main process is User-Agent / Origin / Referer
// only (`download-request-headers.ts`), with no Cookie or Authorization.
// So the static shortcut is offered only for a URL that needs no portal
// credentials — i.e. one the shared classifier says is NOT portal-owned.
// A same-host movie keeps going through `create_link`, whose minted URL
// carries its own access token.
const staticCandidate = resolveStalkerStaticPlaybackUrl(
itemData,
cmdToUse
);
const staticUrlIsSelfAuthenticating =
staticCandidate !== null &&
!isStalkerStreamCredentialSafe(playlist.portalUrl, staticCandidate);
const url = await deps.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
itemData
staticUrlIsSelfAuthenticating ? itemData : undefined
);
if (!url) {
return;