fix(stalker): fall back to create_link when a portal-owned static url has no session

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 10:21:17 +02:00
1 parent 277e0a0385
commit 01d9785c3e
5 files changed
+132 -19

No files matched your search

+15 -4
View File
@@ -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
@@ -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<void> {
await ensureStalkerSession(
): Promise<boolean> {
return ensureStalkerSession(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
@@ -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
@@ -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;
}
}
}
@@ -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<void> {
): Promise<boolean> {
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;
}
}