mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
277e0a0385
commit
01d9785c3e
5 files changed
+132
-19
No files matched your search
@@ -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,
|
||||
|
||||
+61
@@ -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
|
||||
|
||||
+21
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user