mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(stalker): only mint a temporary link when the row asks for one (#1364)
* fix(stalker): only mint a temporary link when the row asks for one `create_link` ran on every Stalker playback. The reference client — the portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise it plays the static `cmd` that `get_all_channels` / `get_ordered_list` already returned. Neither flag was read anywhere in the codebase, so every channel paid a round trip and gained a failure point the reference client does not have. One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path, and by `StreamResolverService` for Favorites/Recently Viewed. Its guards are deliberately wider than the flags alone and can only route a row back onto the `create_link` path: no row to read flags from, a relative or query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host. An episode always mints, since `series` selects it server-side. Radio joins the same decision, so a station the portal proxies now gets its link instead of playing a URL the portal never meant to serve. Temporary links live ~5 s, so the audit that came with this: favorites and recently-viewed persist the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path — none replay a resolved URL. Downloads are the documented exception, and honouring the flags shrinks even that, since an unflagged movie now yields a permanent URL that survives retry. `forced_storage` and `play_token` stay unwired, with the reasoning recorded in the docs rather than left ambiguous. The mock's ITV/radio rows now carry both flags, and the new `static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows with a playable command so the e2e can assert that NO `create_link` request reaches the portal — verified to fail when the change is reverted, with a companion test proving the recorder sees a link when one is due. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): keep temporary-link flags across VOD normalization Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a raw portal row to an explicit whitelist, and the two flags were not on it. It feeds both `selectedItem()` — which the VOD playback path reads as `linkFlags` — and, through `createStalkerVodItem`, the download payload. So a flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took the static path, playing the portal's non-final URL instead of minting a link. The direction of the failure is what makes it a P1: a dropped flag reads as "no temporary link needed", so the whitelist fails OPEN. Both flags now sit on `StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the consequence spelled out at the normalizer so the next edit does not quietly undo it, and specs pinning all three normalizers plus a store-level test that a flagged VOD still mints. Also two things from re-reading my own diff: - The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the same row to `fetchStalkerPlaybackLink`, which runs that exact check again. Two copies of one decision is the divergence this PR exists to remove, so the outer call and its now-unreachable guard are gone. - `portal-catalog-facade.ts` spells the flag shape out instead of importing `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared` may not depend on `type:data-access`/`domain:stalker`), so the obvious "reuse the type" cleanup does not get made and break the boundary lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): authenticate before serving a static collection stream Second Codex P1 on #1364, and a regression this PR introduced. `create_link` was also the request that warmed the portal session. Tokens live in memory only (`StalkerSessionService.tokenCache` is a plain Map), and the collection header builder reads the raw `getCachedToken()`. So a cold start from global Favorites or Recently Viewed — the portal never opened this session — took the static path, found no token, and handed a same-host gated stream headers with no `Authorization`: a 403 on exactly the streams the header contract exists for. The same raw accessor cannot tell a token negotiated for a pre-edit identity from a current one. `StreamResolverService` now calls `ensureToken()` before building a static playback. It is the right primitive: handshake + `get_profile` with no link minted, identity fingerprint validated, concurrent callers deduped, and an immediate null for simple portals — and calling it keeps this change out of `stalker-session.service.ts`, which PR 6 (#1354) is splitting. Best-effort by design: a static URL may point at a CDN that needs no credentials, so a failed handshake degrades to the token-less header set instead of costing the user their playback. Both halves are pinned by tests, and removing the call makes the cold-start test fail. The portal routes need no equivalent and do not get one: an item cannot be selected before its catalog has loaded, and every catalog load authenticates. That reasoning is now written down rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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> * docs(stalker): record the cached-token revalidation trade-off Codex flagged that the static path no longer self-heals a retired token, since `ensureToken` returns a same-identity cache entry without a network call — whereas `create_link` used to refresh it through `makeAuthenticatedRequest`'s auth-failure retry. The mechanism it posits does not exist on stock Stalker: per the 4.9.35 reference, handshake tokens have no TTL, and not sending the watchdog does not invalidate auth (it only clears the admin panel's "online" flag). The real residual vector is another device calling `get_profile` on the same MAC, which is common enough on shared subscriptions to be worth naming. Revalidating on every static playback would cost exactly the round trip this change removes, so it is deliberately not done. Recorded as a known trade-off with its mitigation (a running watchdog still self-heals within a ping cycle) and handed to PR 6, where a refresh on an OBSERVED playback authorization failure belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): tighten the token-revalidation trade-off wording Greptile review feedback: the watchdog mitigation was the most important part of that paragraph and sat behind the caveat. It now follows the MAC-sharing vector directly, and the paragraph ends by naming what is actually left uncovered — a same-host static stream played while no watchdog is up — so a future reader can size the residual without re-deriving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): prefer the live playlist row over a stale favorite snapshot Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as `item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link branch quietly corrected for that afterwards by re-reading `applyOverride(playlist).portalUrl`, so the row won wherever it existed, which is what the comment right above it already promised: "when the row exists it wins over the item's snapshot of the portal URL (a repaired endpoint must beat a stale favorite)". The static branch I added returns before that correction, so it shipped the stale snapshot. Consequences after a playlist edit: a same-host static URL matching the OLD host gets the newly negotiated token and identity headers sent to the previous portal, and a MAC-only edit pairs the new token with the old MAC cookie — precisely the pairing `stalkerIdentityFingerprint` exists to prevent. Both branches now derive the coordinates once, row-first with the repair override applied, and fall back to the item's snapshot only for a playlist that no longer exists — which is the role `buildStalkerPlayback` already documents for it. Mutation-checked: restoring item-first precedence fails the new test alone. Also documents a local-only e2e hazard found while re-running the suite: `mode: 'serial'` orders tests within one project, but chromium/firefox/webkit run the file concurrently against the same mock server, so one project's beforeEach reset can drop a session another is mid-test on — which is what a lone auth-spec failure that passes on rerun actually is. CI never sees it; the Web E2E job runs --project=chromium alone, and that command is clean (22/22). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): warm the session against the repaired portal configuration Found while auditing my own static branch against the create_link path rather than waiting for the next review round. `executeStalkerRequest` applies the lazy-repair override on its first line, so the create_link path always talks to the configuration a completed repair proved good. The session warm-up I added did not: it handed `ensureToken` the caller's pre-repair row, so a portal whose endpoint or mode had been repaired would handshake against the configuration the repair had already rejected — stranding the session precisely on the portals repair exists to rescue. The override now happens inside `ensureStalkerSession`, mirroring `executeStalkerRequest`'s first line, so every caller inherits the rule instead of each having to remember it. Mutation-checked: dropping the override fails the new test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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> * fix(stalker): require flag evidence before trusting a row as unflagged Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): classify every portal-local IPv6 placeholder Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_` and the IPv4-mapped loopback forms slipped past the exact-name set and would have been handed to the player as real addresses. Checked how `URL` actually normalizes these rather than guessing at the spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]` arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1` and `::`, and decodes the mapped form by its high byte, so the whole of the mapped 127.0.0.0/8 range is covered along with the mapped unspecified address. The dotted tail is still accepted for any engine that leaves it alone. Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and `[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4 decode fails five tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize hostname and scheme spelling before the static verdict Two Codex P2s on #1364, both about trusting how a portal spells things. `http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves identically, but `URL` keeps it for names while dropping it for IP literals (`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read that as a remote host and would have pointed the player at its own loopback. Stripped before classifying. `HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The case-sensitive tests failed SAFE, minting a link instead, but that defeats the contract for a portal that spells it this way, and one whose `create_link` cannot resolve an already-playable row would break. There were five such tests, and only one was on the new static path: the other three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver, where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only reply was appended to the portal base instead of to the command. That is pre-existing, but it is the same bug in the same shared normalizer, and fixing only the half this PR introduced would leave exactly the divergence this PR keeps removing. All five now go through one `hasHttpScheme()`. The response resolver had only indirect coverage, so it gains a direct spec alongside the static-path tests. Mutation-checked: reverting the dot strip and the case-insensitive scheme fails ten tests and nothing else. Also carries a docblock fix noticed on a read-through: the guard list still pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into `isPortalLocalHostname`, which now covers considerably more than that set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize DNS root dots in the shared credential classifier Codex P2 on #1364, extending the `localhost.` fix into `isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a portal on `portal.example` serving `https://portal.example./movie.mkv` classified its own stream as third-party. Wider than the download guard it was reported against: this predicate is the single rule BOTH the renderer playback-header builder and the Electron main-process fallback use to decide whether a stream may carry the mac cookie and Bearer token. A portal-owned stream spelled with the root dot was getting the credential-free profile and would 401 — pre-existing, and exactly the "only VLC works" class this contract exists to prevent. My PR added two new dependencies on the same predicate (the download static guard and the portal-owned fallback), which is how it surfaced. Both sides are normalized, so it stays symmetric, and it can only widen toward "same host" — never toward handing credentials to a different one. A test pins that `evil.portal.example.` is still rejected. Also carries the authority guard found by probing the same class myself rather than waiting for it to be reported: `http:///ch/1` has no authority and `URL` quietly reinterprets the first path segment as the host, so a malformed command reached the player as a nonsense address instead of going to the portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other exotic spellings I probed were already covered — `URL` canonicalizes `127.1`, `2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6 normalize too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(stalker): classify the static url before authenticating Codex P2 on #1364. Both static call sites awaited the session warm-up and only then asked whether the stream needed portal credentials at all — so a movie or channel on a foreign CDN paid for a handshake whose result was immediately discarded. That is not free: non-`create_link` requests carry a 15 s timeout (`stalker.events.ts`), so a portal that is slow or offline stalled playback of a stream the CDN would have served instantly. Cold Favorites/Recently Viewed starts are exactly where this bites, since that is where the session is not warm already. Classification now runs first. Foreign host returns immediately, portal-owned still warms and still falls back to `create_link` without a usable session. Behaviour is otherwise unchanged; only the order and the wasted wait are gone. Two tests moved with it: the foreign-host case now asserts the portal is not contacted at all rather than merely not asked for a link, and the repaired-endpoint case had been written against a foreign-host command, which under the new ordering correctly never reaches the handshake it was meant to be testing — it uses a portal-owned command now. Mutation-checked: restoring warm-before-classify fails the foreign-host test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): repoint two handshake tests at the path they claim to cover Self-audit, prompted by the previous round: the reorder exposed one test that was asserting through a path it no longer reached, so I checked the rest of that class rather than assume it was the only one. Two more had the same defect, both mine. `still returns the static url when the handshake fails` (both specs) mocked `ensureToken` to reject, but used a FOREIGN-host command. Now that classification runs before authentication, that command returns before the handshake is ever attempted — the rejection was never exercised and the test passed on the early return instead of the mechanism in its name. Worse, the foreign case is already covered by the test added alongside the reorder, so these were asserting nothing new. Both now use a portal-owned command, which is what actually reaches the handshake, and assert what a throw really produces: `ensureStalkerSession` swallows it, the verdict is false, and the row falls back to `create_link` rather than being served as a known 401. Each asserts `ensureToken` was in fact called, so neither can silently drift back into testing an early return. Docs corrected with them: the "best-effort degrades to the token-less header set" wording described behaviour the reorder removed. A foreign-host URL is now returned before any handshake, and a failed one routes to `create_link`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): make the simple-portal skip test prove portal mode Fourth test found passing through the wrong exit, from auditing all ten in the block rather than waiting to trip over another one. `skips the handshake for a simple portal` used a foreign-host command, so the classification step returned before the warm-up was reached. `ensureToken` was indeed not called — but because the host was foreign, not because the portal was simple, and the assertion could not tell those apart. The command is now portal-owned, so the skip can only come from the mode, and the test also pins the returned URL and that no request was made. Mutation-checked properly this time: removing the simple-portal early return from `ensureStalkerSession` now fails this test. Under the old command it would not have. Also records the pattern where the next person will meet it. The decision chain has several exits — no flag evidence, unresolvable command, `series` set, foreign host, unusable session — and more than one can satisfy the same assertion, so a foreign-host command silently stands in for "simple portal" or "handshake failed". Mutation testing does not catch that class: it proves a test is coupled to its target, not that it reached the mechanism it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): key the radio fallback on flag evidence, not snapshot presence Codex P2 on #1364, and a divergence I introduced myself. `withStalkerPlayer`'s radio branch checks `hasStalkerLinkFlagEvidence(item)` before synthesizing the zero flags. `StreamResolverService` used `??`, which only falls back when the snapshot is absent entirely. A radio Favorite or Recent row persisted before the flags were carried HAS a snapshot — the old whitelist just stripped the flags out of it — so the `??` selected that flagless object, the helper found no evidence, and the collection route began minting for exactly the rows that used to play directly. That breaks portals whose radio `create_link` is unsupported, which is the case the radio exception exists for. The two paths now apply the identical rule. The divergence came from fixing them in different rounds and is precisely the class this PR keeps closing, so the comment on each side now points at the other. The existing radio test carries no `stalkerItem` at all, so it exercises the missing-snapshot arm and stayed green throughout — the same "passes through a different exit" pattern documented in the section above. The new test supplies a present-but-flagless snapshot. Mutation-checked: restoring the presence check fails it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): treat every reserved localhost name as portal-local Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` for the loopback interface, and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the player's own machine instead of being sent to the portal to resolve. Closed the class rather than adding one more name: the suffix is matched, and `localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. Together with the earlier rounds the predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`, `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to hex, and a terminal DNS root dot on any of them. Only the suffix is reserved, so the guard must not over-match: tests pin that `localhost.cdn.example` and `notlocalhost` remain ordinary routable names and keep playing statically. Mutation-checked: dropping the suffix rule and the localdomain alias fails four tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d44948f2fa
commit
e197409b10
31 files changed
+2459
-175
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker channels that the portal serves directly now start without an extra
|
||||
link request to the portal, so they open faster and no longer fail when that
|
||||
request does. Channels the portal does proxy still get their temporary link,
|
||||
and radio stations the portal proxies now get one too instead of playing a URL
|
||||
the portal never meant to serve.
|
||||
@@ -698,6 +698,23 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Xtream Codes API (`username`, `password`, `serverUrl`)
|
||||
- Stalker portal (`macAddress`, `url`)
|
||||
|
||||
**Stalker playback links**: `create_link` runs only when the catalog row sets
|
||||
`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays
|
||||
directly. One helper decides
|
||||
(`resolveStalkerStaticPlaybackUrl` in
|
||||
`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied
|
||||
by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by
|
||||
`StreamResolverService` for Favorites/Recently Viewed. It falls back to
|
||||
`create_link` for anything it cannot resolve alone: no row to read flags from,
|
||||
a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,
|
||||
or a loopback host; an episode (`series` set) always mints, since the parameter
|
||||
selects the episode server-side. Temporary links live ~5 s, so no resolved URL
|
||||
is persisted or replayed — favorites and recently-viewed store the `cmd`,
|
||||
playback positions store ids, and the main-process context map stores headers
|
||||
keyed by origin+path. Downloads are the one exception (they must retry a URL).
|
||||
`forced_storage`/`play_token` are deliberately unwired. Contract:
|
||||
`docs/architecture/stalker-portal.md` ("Playback Link Resolution").
|
||||
|
||||
**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed
|
||||
on the command line, opened through a file association, or delivered by macOS'
|
||||
`open-file` event is normalized to an absolute path in the main process
|
||||
|
||||
@@ -106,4 +106,47 @@ describe('stalker playback context', () => {
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
describe('temporary-link retention', () => {
|
||||
// A Stalker temporary link expires after ~5 s. This map is a
|
||||
// header lookup keyed BY the stream URL the player is already
|
||||
// opening — it must never become a place a stale URL can be read
|
||||
// back out of and replayed.
|
||||
it('stores headers only, never the URL it was keyed with', () => {
|
||||
const streamUrl =
|
||||
'http://tmp-link.example.test/ch/1?token=SECRET-TMP';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://tmp-link.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(Object.keys(headers).length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(headers)).not.toContain('SECRET-TMP');
|
||||
expect(JSON.stringify(headers)).not.toContain('/ch/1');
|
||||
});
|
||||
|
||||
it('matches a re-minted link that differs only in its expiring query', () => {
|
||||
// Consecutive create_link calls return the same path with a fresh
|
||||
// token; the lookup keys on origin+path so the second link still
|
||||
// finds its portal headers instead of playing bare.
|
||||
const firstLink = 'http://tmp-link2.example.test/ch/7?token=first';
|
||||
const secondLink = 'http://tmp-link2.example.test/ch/7?token=second';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl: firstLink,
|
||||
portalUrl:
|
||||
'http://tmp-link2.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
getStalkerPlaybackContextHeaders(secondLink)?.['Cookie']
|
||||
).toContain(`mac=${macAddress}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,8 +83,16 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
Every generated ITV channel and radio station carries the two temporary-link
|
||||
flags a real portal sends. Outside the `static-channel-cmd` scenario they are
|
||||
`use_http_tmp_link: '1'`, which is the honest annotation of their
|
||||
`ffrt4://…` commands: those are portal-internal pseudo-URLs that only
|
||||
`create_link` can resolve.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|
||||
@@ -17,7 +17,17 @@ export interface RawCategory {
|
||||
censored?: string;
|
||||
}
|
||||
|
||||
export interface RawChannel {
|
||||
/**
|
||||
* The two flags that tell a client whether the row needs `create_link`. Real
|
||||
* portals send them on every ITV/radio row as `'0'`/`'1'` strings; a client
|
||||
* that honours them plays the static `cmd` when both are `'0'`.
|
||||
*/
|
||||
export interface RawTemporaryLinkFlags {
|
||||
use_http_tmp_link: '0' | '1';
|
||||
use_load_balancing: '0' | '1';
|
||||
}
|
||||
|
||||
export interface RawChannel extends RawTemporaryLinkFlags {
|
||||
id: string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
@@ -28,7 +38,7 @@ export interface RawChannel {
|
||||
xmltv_id: string;
|
||||
}
|
||||
|
||||
export interface RawRadioStation {
|
||||
export interface RawRadioStation extends RawTemporaryLinkFlags {
|
||||
id: string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
@@ -238,7 +248,12 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
@@ -377,7 +392,12 @@ function generateCategories(
|
||||
// Channel generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] {
|
||||
function generateChannels(
|
||||
categoryId: string,
|
||||
count: number,
|
||||
startIndex: number,
|
||||
staticCmd: boolean
|
||||
): RawChannel[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const globalIndex = startIndex + i;
|
||||
const id = String(10000 + globalIndex);
|
||||
@@ -386,11 +406,19 @@ function generateChannels(categoryId: string, count: number, startIndex: number)
|
||||
id,
|
||||
name,
|
||||
o_name: name,
|
||||
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
// A static row carries a playable address with the usual
|
||||
// `<solution> <url>` prefix; the default `ffrt4://` command is a
|
||||
// portal-internal pseudo-URL that only `create_link` can resolve,
|
||||
// which is exactly what `use_http_tmp_link` announces.
|
||||
cmd: staticCmd
|
||||
? `ffrt3 ${pickStream(globalIndex)}`
|
||||
: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
logo: logoUrl(`ch-${id}`),
|
||||
category_id: categoryId,
|
||||
tv_genre_id: categoryId,
|
||||
xmltv_id: `channel-${id}.example`,
|
||||
use_http_tmp_link: staticCmd ? '0' : '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -414,6 +442,8 @@ function generateRadioStations(
|
||||
tv_genre_id: categoryId,
|
||||
number: String(globalIndex + 1),
|
||||
radio: true,
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface ScenarioConfig {
|
||||
* the portal credentials (the "only VLC works" cluster).
|
||||
*/
|
||||
gatedStream?: true;
|
||||
/**
|
||||
* Generate ITV channels that need NO temporary link: a directly playable
|
||||
* `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'`. A client
|
||||
* honouring the flags must play those without calling `create_link`.
|
||||
*/
|
||||
staticChannelCmd?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +163,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
embeddedSeriesFraction: 0,
|
||||
gatedStream: true,
|
||||
},
|
||||
'00:1a:79:00:00:0a': {
|
||||
name: 'static-channel-cmd',
|
||||
description:
|
||||
'ITV rows that need no temporary link — playable cmd with ' +
|
||||
'use_http_tmp_link/use_load_balancing both 0',
|
||||
seed: 1010,
|
||||
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 5,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
staticChannelCmd: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,16 @@ import {
|
||||
*
|
||||
* Giving each test its own MAC instead would mean inventing a scenario per
|
||||
* test; serializing one file is the cheaper trade.
|
||||
*
|
||||
* ACROSS BROWSER PROJECTS this does NOT hold, and it is a local-run hazard
|
||||
* only. `mode: 'serial'` orders tests within one project; chromium, firefox
|
||||
* and webkit still run the file concurrently against the SAME mock server, so
|
||||
* one project's `beforeEach` reset can drop a session another project is
|
||||
* mid-test on — the auth specs below are the ones that notice, failing as if
|
||||
* the portal had dropped them. CI never sees it: the Web E2E job runs
|
||||
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
|
||||
* all-project run shows a lone auth failure that passes on rerun, this is why;
|
||||
* `--project=chromium` reproduces CI exactly.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -65,6 +75,13 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
|
||||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
|
||||
/**
|
||||
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
|
||||
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
|
||||
* expects no `create_link` call at all.
|
||||
*/
|
||||
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
||||
|
||||
/**
|
||||
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
|
||||
* by MAC, so keeping these distinct from the content scenarios above means an
|
||||
@@ -120,6 +137,7 @@ const OWNED_MACS = [
|
||||
MINIMAL_MAC,
|
||||
EMBEDDED_SERIES_MAC,
|
||||
LEGACY_PAGINATION_MAC,
|
||||
STATIC_CMD_MAC,
|
||||
AUTH_FLOW_MAC,
|
||||
AUTH_REAUTH_MAC,
|
||||
AUTH_REJECTED_MAC,
|
||||
@@ -681,6 +699,62 @@ test('@stalker create_link returns a playable stream URL', async ({
|
||||
expect(streamUrl).toMatch(/\.m3u8$/);
|
||||
});
|
||||
|
||||
/**
|
||||
* Play the first channel of the first ITV category and report every
|
||||
* `create_link` request the page made while doing so.
|
||||
*/
|
||||
async function playFirstItvChannel(page: Page): Promise<string[]> {
|
||||
const createLinkRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=create_link')) {
|
||||
createLinkRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
await expect(page.locator('app-web-player-view')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
return createLinkRequests;
|
||||
}
|
||||
|
||||
test('@stalker ITV plays an unflagged channel without minting a link', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The reference client only calls create_link when the row sets
|
||||
// use_http_tmp_link or use_load_balancing; this portal sets neither, so
|
||||
// the static cmd must reach the player untouched. The companion test
|
||||
// below proves the recorder does see a create_link when one is due.
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Static Cmd Portal',
|
||||
mac: STATIC_CMD_MAC,
|
||||
});
|
||||
|
||||
expect(await playFirstItvChannel(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test('@stalker ITV mints a link for a channel that asks for one', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page, { name: 'Tmp Link Portal' });
|
||||
|
||||
const createLinkRequests = await playFirstItvChannel(page);
|
||||
|
||||
expect(createLinkRequests.length).toBeGreaterThan(0);
|
||||
expect(createLinkRequests[0]).toContain('type=itv');
|
||||
});
|
||||
|
||||
test('@stalker mock server returns radio categories and stations', async ({
|
||||
request,
|
||||
}) => {
|
||||
|
||||
@@ -297,6 +297,9 @@ interface ScenarioConfig {
|
||||
supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
|
||||
// without the ITV get_all_channels action
|
||||
marketingFixture?: true; // replace generated VOD with shared posters
|
||||
requiresLogin?: true; // get_profile answers status 2 until do_auth
|
||||
gatedStream?: true; // create_link returns a credential-gated URL
|
||||
staticChannelCmd?: true; // ITV rows need no temporary link
|
||||
}
|
||||
```
|
||||
|
||||
@@ -317,6 +320,16 @@ forwarded host/protocol) as
|
||||
`<portal-origin>/assets/marketing/poster/<slug>.png`. `main.ts` serves the
|
||||
committed PNG directory directly, so the Xtream server does not need to run.
|
||||
|
||||
Every generated ITV channel and radio station carries `use_http_tmp_link` and
|
||||
`use_load_balancing`, the flags a real portal uses to tell a client whether the
|
||||
row needs `create_link`. They are `'1'`/`'0'` for the default generators —
|
||||
honest, because those rows carry `ffrt4://…` pseudo-URLs. The
|
||||
`static-channel-cmd` scenario (`00:1A:79:00:00:0A`) sets `staticChannelCmd`,
|
||||
which gives ITV rows both flags at `'0'` and a real
|
||||
`ffrt3 https://…m3u8` command, so `apps/web-e2e/src/stalker.e2e.ts` can assert
|
||||
that no `create_link` request reaches the portal. See
|
||||
`docs/architecture/stalker-portal.md`, "Playback Link Resolution".
|
||||
|
||||
### Adding a New Scenario
|
||||
|
||||
1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`.
|
||||
|
||||
@@ -305,6 +305,226 @@ the cross-portal collection resolver (`StreamResolverService`) use
|
||||
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
|
||||
replies against the portal base URL.
|
||||
|
||||
## Playback Link Resolution
|
||||
|
||||
### When `create_link` is called
|
||||
|
||||
A Stalker catalog row decides for itself whether it needs a temporary link.
|
||||
The portal's own `player.js` — mirrored by Kodi's `pvr.stalker` — calls
|
||||
`create_link` only when the row sets `use_http_tmp_link` (the portal proxies
|
||||
the stream through a per-session URL) or `use_load_balancing` (the portal
|
||||
picks a storage server per request). Every other row plays the static `cmd`
|
||||
that `get_all_channels` / `get_ordered_list` already returned. Until PR 8 the
|
||||
app called `create_link` unconditionally, so every playback paid a round trip
|
||||
and gained a failure point that the reference client does not have.
|
||||
|
||||
One helper owns the decision:
|
||||
`resolveStalkerStaticPlaybackUrl(row, cmd)` in
|
||||
`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts`.
|
||||
It returns the playable URL when the static path applies and `null` when the
|
||||
portal has to resolve the command. `null` is deliberately wider than the flag
|
||||
check alone; the extra guards can only push a row back onto the `create_link`
|
||||
path, so they cannot regress a portal that works today:
|
||||
|
||||
| Input | Verdict | Why |
|
||||
| --- | --- | --- |
|
||||
| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. |
|
||||
| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. |
|
||||
| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. |
|
||||
| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. |
|
||||
| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. |
|
||||
| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. |
|
||||
| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. |
|
||||
|
||||
`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and
|
||||
short-circuits before the request. It never applies it when `series` is set:
|
||||
an episode is selected server-side by that parameter, so the parent row's
|
||||
static `cmd` addresses the series, not the episode.
|
||||
|
||||
**The flags must survive normalization.** `buildStalkerSelectedVodItem()`
|
||||
(`stalker-vod.utils.ts`) narrows a raw portal row to a whitelist, so a field it
|
||||
does not name is silently dropped — and it feeds both VOD playback
|
||||
(`selectedItem()`) and the download payload (`createStalkerVodItem` passes its
|
||||
`data` straight through). Losing a flag there fails **open**: the row reads as
|
||||
"no temporary link needed" and the static path plays the portal's non-final
|
||||
URL. Both flags are therefore on the whitelist, on `StalkerVodSource` /
|
||||
`StalkerSelectedVodItem`, and pinned by tests in `stalker-vod.utils.spec.ts`.
|
||||
Any new normalizer between a portal response and a playback call has the same
|
||||
obligation.
|
||||
|
||||
Callers pass the row they resolved the `cmd` from:
|
||||
|
||||
- ITV and radio — the channel/station row (`withStalkerPlayer`); radio
|
||||
previously bypassed `create_link` for any directly playable command, which
|
||||
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()`, 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`).
|
||||
Radio keeps its long-standing "directly usable command plays as-is"
|
||||
behaviour, keyed off flag EVIDENCE rather than snapshot presence — a radio
|
||||
row persisted before the flags were carried has a snapshot that simply lacks
|
||||
them, and testing presence would skip the fallback for exactly those rows.
|
||||
`withStalkerPlayer`'s radio branch applies the identical rule; the two must
|
||||
not drift.
|
||||
|
||||
### 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 — 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.
|
||||
|
||||
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 classification happens BEFORE the handshake, not after: a **foreign-host**
|
||||
static URL never needs the session at all, and warming it anyway would stall
|
||||
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.
|
||||
|
||||
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
|
||||
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. 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
|
||||
longer self-heals a token the server has retired — something `create_link`
|
||||
used to do for free, since `makeAuthenticatedRequest` retires and re-auths on
|
||||
an authorization failure. This is narrower than it sounds: per the 4.9.35
|
||||
reference, handshake tokens have **no TTL**, and failing to send the watchdog
|
||||
does **not** invalidate auth (it only clears the admin panel's "online"
|
||||
status). The one real vector left is another device performing `get_profile`
|
||||
on the same MAC — common enough on shared subscriptions, but wherever a
|
||||
watchdog is running it still self-heals within a ping cycle, because the ping
|
||||
goes through `makeAuthenticatedRequest` too. What is left uncovered is a
|
||||
same-host static stream played while no watchdog is up.
|
||||
|
||||
Revalidating on every static playback would cost exactly the round trip this
|
||||
section exists to remove, so it is deliberately not done here. The right home
|
||||
for a fix is the auth lifecycle (PR 6): refresh on an observed playback
|
||||
authorization failure, rather than pre-emptively on every play.
|
||||
|
||||
**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
|
||||
|
||||
A temporary link lives about 5 seconds (`tv_tmp_link_ttl` /
|
||||
`vclub_tmp_link_ttl`, both default 5). It is time-limited but not single-use,
|
||||
so the rule is to resolve immediately before playback and never persist,
|
||||
cache or replay the result. What each persisting path actually stores:
|
||||
|
||||
| Path | Stores | Verdict |
|
||||
| --- | --- | --- |
|
||||
| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. |
|
||||
| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. |
|
||||
| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. |
|
||||
| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. |
|
||||
| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. |
|
||||
| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. |
|
||||
|
||||
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: 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`
|
||||
|
||||
Both are deliberately unused, and neither appears in the 4.9.35 reference
|
||||
fact set as a parameter the stock server enforces:
|
||||
|
||||
- **`forced_storage`** is a `create_link` request parameter that pins VOD
|
||||
playback to one storage server. It exists for clients that let the user pick
|
||||
a storage; IPTVnator has no such concept, and omitting the parameter is what
|
||||
produces the empty value the portal treats as "no preference". Wiring it
|
||||
would first need storage discovery and a picker in the VOD detail view.
|
||||
- **`play_token`** is a `create_link` response field for clients that assemble
|
||||
the stream URL themselves. IPTVnator plays the `cmd` the portal returns
|
||||
verbatim (after the solution-prefix strip and base resolution above), so the
|
||||
token the stream needs is already in the URL. Note the coupling with the
|
||||
section above: on the static path no `create_link` runs at all, so no
|
||||
`play_token` is ever produced — which is consistent, because a row that
|
||||
wants neither flag is announcing that its `cmd` needs no portal-minted
|
||||
credential.
|
||||
|
||||
Revisit both only with a portal that demonstrably fails without them.
|
||||
|
||||
### Regression coverage
|
||||
|
||||
**Writing tests against this section:** the decision chain has several exits —
|
||||
no flag evidence, unresolvable command shape, `series` set, foreign host,
|
||||
session unusable — and more than one of them can satisfy the same assertion.
|
||||
Four tests in the PR that introduced this were found passing through an exit
|
||||
other than the one they named (a foreign-host command reaches neither the
|
||||
handshake nor `create_link`, so it silently stands in for "simple portal" or
|
||||
"handshake failed"). Mutation testing does not catch it: it proves a test is
|
||||
coupled to its target, not that it reached the mechanism in its name. Check the
|
||||
mock setup against the execution path, and assert the step you mean was
|
||||
actually taken — `expect(ensureToken).toHaveBeenCalled()` rather than only the
|
||||
returned URL.
|
||||
|
||||
- `stalker-link-semantics.utils.spec.ts` — the decision table above.
|
||||
- `stalker-vod.utils.spec.ts` — both flags survive
|
||||
`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 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
|
||||
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
|
||||
unflagged ITV rows with a playable `cmd`; the spec asserts NO `create_link`
|
||||
request reaches the portal, and a companion test on the default (flagged)
|
||||
scenario proves the recorder does see one when a link is due.
|
||||
|
||||
## Playback Header Contract
|
||||
|
||||
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
|
||||
@@ -326,7 +546,10 @@ live layout for the radio audio player, which renders outside
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host
|
||||
(compared with a terminal DNS root dot normalized away, since
|
||||
`portal.example.` and `portal.example` are the same host but `URL` keeps
|
||||
the dot) —
|
||||
including a different port or an http→https upgrade, the routine IPTV panel
|
||||
shape (#1158 class). These streams get the full MAG profile: `Cookie`
|
||||
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('StreamResolverService', () => {
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
};
|
||||
epgBridge = {
|
||||
getChannelPrograms: jest.fn(),
|
||||
@@ -703,6 +704,48 @@ describe('StreamResolverService', () => {
|
||||
expect(detail.playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('plays a legacy radio favorite whose snapshot lost the flags', async () => {
|
||||
// The row above this one carries NO `stalkerItem`, so it exercises the
|
||||
// missing-snapshot arm. A radio favorite persisted before the flags
|
||||
// were carried has a snapshot that simply lacks them — testing
|
||||
// presence instead of evidence would skip the radio fallback and start
|
||||
// minting for exactly those rows, breaking portals whose radio
|
||||
// `create_link` is unsupported.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const detail = await service.resolveLiveDetail({
|
||||
uid: 'stalker::stalker-1::40003',
|
||||
name: 'Legacy Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '40003',
|
||||
stalkerCmd: 'ffmpeg https://media.example.com/legacy.mp3',
|
||||
logo: 'legacy.png',
|
||||
radio: 'true',
|
||||
// Present, but stripped of the flags by the old whitelist.
|
||||
stalkerItem: {
|
||||
id: '40003',
|
||||
cmd: 'ffmpeg https://media.example.com/legacy.mp3',
|
||||
name: 'Legacy Radio',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
expect(detail.playback.streamUrl).toBe(
|
||||
'https://media.example.com/legacy.mp3'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
@@ -808,6 +851,255 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('plays an unflagged Stalker favorite from its stored cmd without create_link', async () => {
|
||||
// Favorites persist the raw catalog row, so `use_http_tmp_link` /
|
||||
// `use_load_balancing` come back with it — a row that sets neither is
|
||||
// playable as it stands and must not cost a portal round trip.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::90',
|
||||
name: 'Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '90',
|
||||
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
stalkerItem: {
|
||||
id: '90',
|
||||
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
// The detail route still loads EPG; what must not happen is a link
|
||||
// request.
|
||||
const requestedActions = [
|
||||
...dataService.sendIpcEvent.mock.calls,
|
||||
...stalkerSession.makeAuthenticatedRequest.mock.calls,
|
||||
].map(
|
||||
(call) =>
|
||||
(call[1] as { params?: { action?: string } } | undefined)
|
||||
?.params?.action
|
||||
);
|
||||
expect(requestedActions).not.toContain('create_link');
|
||||
expect(playback.streamUrl).toBe('http://cdn.example.com/live/90.m3u8');
|
||||
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<Playlist>)
|
||||
);
|
||||
// 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('serves a foreign-host static stream without waiting on the portal', async () => {
|
||||
// The CDN needs no portal credentials, so the handshake would be a
|
||||
// discarded result — and a stall of up to 15 s when the portal is
|
||||
// slow or offline but the CDN is fine.
|
||||
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<Playlist>)
|
||||
);
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::94',
|
||||
name: 'Cdn Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '94',
|
||||
stalkerCmd: 'ffrt3 https://cdn.example.com/live/94.m3u8',
|
||||
stalkerItem: {
|
||||
id: '94',
|
||||
cmd: 'ffrt3 https://cdn.example.com/live/94.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe('https://cdn.example.com/live/94.m3u8');
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
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: 'Portal Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '92',
|
||||
stalkerCmd: 'ffrt3 https://stalker.example.com/live/92.m3u8',
|
||||
stalkerItem: {
|
||||
id: '92',
|
||||
cmd: 'ffrt3 https://stalker.example.com/live/92.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
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 () => {
|
||||
// A favorite persists the portal URL and MAC it was saved with. After
|
||||
// the playlist is edited, the session token is negotiated for the NEW
|
||||
// identity — sending it with the old MAC cookie (or to the old host)
|
||||
// is the mismatch the identity fingerprint exists to prevent.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://new.example.com/portal.php',
|
||||
macAddress: 'AA:BB:CC:00:00:99',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW');
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::93',
|
||||
name: 'Edited Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '93',
|
||||
stalkerCmd: 'ffrt3 https://new.example.com/live/93.m3u8',
|
||||
// Stale snapshot from before the edit.
|
||||
stalkerPortalUrl: 'https://old.example.com/portal.php',
|
||||
stalkerMacAddress: '00:11:22:33:44:55',
|
||||
stalkerItem: {
|
||||
id: '93',
|
||||
cmd: 'ffrt3 https://new.example.com/live/93.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toContain('mac=AA:BB:CC:00:00:99');
|
||||
expect(playback.headers?.['Cookie']).not.toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
// Same host as the edited row ⇒ portal-owned ⇒ credentials attached.
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-NEW');
|
||||
expect(playback.origin).toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
it('mints a link for a flagged Stalker favorite', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg http://cdn.example.com/tmp/90.m3u8?tok=1' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::90',
|
||||
name: 'Balanced Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '90',
|
||||
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
stalkerItem: {
|
||||
id: '90',
|
||||
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
use_load_balancing: '1',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ action: 'create_link' }),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example.com/tmp/90.m3u8?tok=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
Playlist,
|
||||
isStalkerStreamCredentialSafe,
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
@@ -19,15 +20,21 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
ensureStalkerSession,
|
||||
executeStalkerRequest,
|
||||
getStalkerPortalOrigin,
|
||||
hasStalkerLinkFlagEvidence,
|
||||
isCrossOriginStalkerStream,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
StalkerPortalRepairService,
|
||||
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[] };
|
||||
@@ -74,6 +81,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<string, XtreamEpgCacheEntry>();
|
||||
@@ -330,18 +338,84 @@ export class StreamResolverService {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
// The playlist row wins whenever it exists: a repaired endpoint or an
|
||||
// edited MAC must beat the snapshot a favorite persisted, and the
|
||||
// session token is negotiated for the ROW's identity — pairing a fresh
|
||||
// token with a stale MAC cookie is exactly the mismatch the identity
|
||||
// fingerprint exists to prevent. The item's own coordinates are the
|
||||
// fallback for a playlist that no longer exists.
|
||||
const currentPlaylist = playlist
|
||||
? this.portalRepair.applyOverride(playlist)
|
||||
: undefined;
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(
|
||||
currentPlaylist?.portalUrl ??
|
||||
currentPlaylist?.url ??
|
||||
item.stalkerPortalUrl ??
|
||||
'';
|
||||
const macAddress =
|
||||
currentPlaylist?.macAddress ?? item.stalkerMacAddress ?? '';
|
||||
// Favorites and Recently Viewed persist the raw catalog row, so the
|
||||
// temporary-link flags travel with the item and this route makes the
|
||||
// same decision the portal views make: an unflagged, directly playable
|
||||
// `cmd` plays as-is and never mints a 5 s link. An item that carries
|
||||
// no row snapshot (router state holds only the projection) gets no
|
||||
// verdict — except radio, whose directly usable commands have always
|
||||
// played as-is, so it keeps behaving like a row without flags.
|
||||
const snapshot = item.stalkerItem as StalkerLinkFlagSource | undefined;
|
||||
// Radio keeps its historical rule: a directly usable command plays as
|
||||
// as-is. That has to key off flag EVIDENCE, not merely a present
|
||||
// snapshot — a radio row persisted before the flags were carried has a
|
||||
// snapshot that lacks them, and testing presence alone would skip this
|
||||
// fallback and start minting for exactly those rows. Mirrors
|
||||
// `withStalkerPlayer`'s radio branch; the two must not drift.
|
||||
const linkFlags =
|
||||
item.radio === 'true' && !hasStalkerLinkFlagEvidence(snapshot)
|
||||
? {
|
||||
...(snapshot ?? {}),
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
}
|
||||
: snapshot;
|
||||
const staticUrl = resolveStalkerStaticPlaybackUrl(
|
||||
linkFlags,
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: normalizedCmd,
|
||||
});
|
||||
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. The raw row goes
|
||||
// in: the helper applies the repair override itself, exactly as
|
||||
// `executeStalkerRequest` does on the branch below.
|
||||
//
|
||||
// Classified BEFORE authenticating: a foreign-host stream never
|
||||
// needs the session, and warming it anyway would stall playback
|
||||
// behind a handshake against a portal that may be slow or offline
|
||||
// while the CDN is perfectly reachable.
|
||||
const servePlayback = () =>
|
||||
this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: staticUrl,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
});
|
||||
|
||||
if (!isStalkerStreamCredentialSafe(portalUrl, staticUrl)) {
|
||||
return servePlayback();
|
||||
}
|
||||
|
||||
// Portal-owned 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 (await this.warmStalkerSession(playlist)) {
|
||||
return servePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = item.radio === 'true' ? 'radio' : 'itv';
|
||||
@@ -404,6 +478,25 @@ export class StreamResolverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish the portal session a static stream may still be gated on.
|
||||
* 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<boolean> {
|
||||
return ensureStalkerSession(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
this.logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection routes must hand players the SAME portal header set the
|
||||
* Stalker live layout builds — an auth-gated stream opened from Favorites
|
||||
@@ -1168,10 +1261,6 @@ export class StreamResolverService {
|
||||
}));
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* All keys a manual mapping for this channel may have been saved under:
|
||||
* the playlist-scoped Xtream key first, then the M3U lookup keys.
|
||||
|
||||
@@ -87,10 +87,22 @@ export interface StalkerPortalCatalogFacade<
|
||||
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void;
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void): void;
|
||||
fetchMovieFileId(itemId: string): Promise<string | null>;
|
||||
/**
|
||||
* `linkFlags` carries the catalog row's `use_http_tmp_link` /
|
||||
* `use_load_balancing`; without it the portal is always asked for a
|
||||
* temporary link.
|
||||
*
|
||||
* The shape is spelled out rather than imported as `StalkerLinkFlagSource`
|
||||
* on purpose: this lib is `type:util`/`domain:portal-shared` and may not
|
||||
* depend on `portal-stalker-data-access` (`type:data-access`/`domain:stalker`)
|
||||
* — the Nx module-boundary rule rejects that edge.
|
||||
*/
|
||||
fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
series?: number,
|
||||
linkFlags?: { use_http_tmp_link?: unknown; use_load_balancing?: unknown }
|
||||
): Promise<string>;
|
||||
resolveVodPlayback(
|
||||
cmd?: string,
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface StalkerVodSource {
|
||||
cmd?: string;
|
||||
series?: unknown[];
|
||||
has_files?: number;
|
||||
/**
|
||||
* Temporary-link flags the portal sets on a row. Carried through every
|
||||
* normalizer because playback and downloads read them to decide whether
|
||||
* `create_link` is required — dropping one fails open.
|
||||
*/
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
is_series?: boolean | number | string | null | undefined;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
@@ -47,6 +54,9 @@ export interface StalkerSelectedVodItem extends StalkerVodDetails {
|
||||
is_series?: true;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
/** See {@link StalkerVodSource.use_http_tmp_link}. */
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,14 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerFavoriteItem } from './models';
|
||||
import {
|
||||
buildStalkerFavoritePayload,
|
||||
buildStalkerSelectedVodItem,
|
||||
createStalkerInfo,
|
||||
createStalkerInlineDetailState,
|
||||
createStalkerDetailViewState,
|
||||
isStalkerSeriesFlag,
|
||||
normalizeStalkerFavoriteItem,
|
||||
normalizeStalkerSeriesFlag,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from './stalker-vod.utils';
|
||||
|
||||
@@ -286,4 +288,52 @@ describe('stalker-vod.utils regressions', () => {
|
||||
expect(info.tmdb_trailer).toBe('abc123def');
|
||||
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
|
||||
});
|
||||
|
||||
describe('temporary-link flags survive normalization', () => {
|
||||
// `buildStalkerSelectedVodItem` is a whitelist, so a field it forgets
|
||||
// is silently gone. For these two that fails OPEN: playback and
|
||||
// downloads would read "no temporary link needed" and play the
|
||||
// portal's non-final URL instead of minting one.
|
||||
const FLAGGED_ROW = {
|
||||
id: '42',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
info: { name: 'Flagged Movie' },
|
||||
};
|
||||
|
||||
it('keeps both flags on the selected VOD item', () => {
|
||||
const selected = buildStalkerSelectedVodItem(FLAGGED_ROW);
|
||||
|
||||
expect(selected.use_http_tmp_link).toBe('1');
|
||||
expect(selected.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps both flags through the VOD details normalizer', () => {
|
||||
const normalized = normalizeStalkerVodDetailsItem(FLAGGED_ROW);
|
||||
|
||||
expect(normalized.use_http_tmp_link).toBe('1');
|
||||
expect(normalized.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps both flags on a normalized favorite', () => {
|
||||
const normalized = normalizeStalkerFavoriteItem(
|
||||
FLAGGED_ROW as StalkerFavoriteItem
|
||||
);
|
||||
|
||||
expect(normalized.details.use_http_tmp_link).toBe('1');
|
||||
expect(normalized.details.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('leaves an unflagged row without inventing flags', () => {
|
||||
const selected = buildStalkerSelectedVodItem({
|
||||
id: '43',
|
||||
cmd: 'ffrt3 http://cdn.example/other.mkv',
|
||||
info: { name: 'Plain Movie' },
|
||||
});
|
||||
|
||||
expect(selected.use_http_tmp_link).toBeUndefined();
|
||||
expect(selected.use_load_balancing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,16 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a raw portal row to the fields the detail/playback flows need.
|
||||
*
|
||||
* This is a whitelist, not a spread, so anything not named here is dropped.
|
||||
* `use_http_tmp_link` / `use_load_balancing` MUST stay on the list: playback
|
||||
* and downloads read them to decide whether `create_link` is required, and a
|
||||
* missing flag reads as "no temporary link needed" — it fails OPEN, playing
|
||||
* the portal's non-final URL instead of minting one. See
|
||||
* `docs/architecture/stalker-portal.md`, "Playback Link Resolution".
|
||||
*/
|
||||
export function buildStalkerSelectedVodItem(
|
||||
item: StalkerVodSource,
|
||||
forceSeries = false
|
||||
@@ -169,6 +179,8 @@ export function buildStalkerSelectedVodItem(
|
||||
cmd: toStringOrFallback(item.cmd),
|
||||
series: item.series,
|
||||
has_files: item.has_files,
|
||||
use_http_tmp_link: item.use_http_tmp_link,
|
||||
use_load_balancing: item.use_load_balancing,
|
||||
is_series:
|
||||
forceSeries || isStalkerSeriesFlag(item?.is_series)
|
||||
? true
|
||||
|
||||
+193
-20
@@ -31,6 +31,20 @@ const PLAYLIST = {
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
interface SelectedTestItem {
|
||||
id: string;
|
||||
cmd: string;
|
||||
has_files?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
cover?: string;
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
const TestPlayerStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
@@ -41,31 +55,13 @@ const TestPlayerStore = signalStore(
|
||||
has_files: true,
|
||||
title: 'Original Title',
|
||||
category_id: 'vod',
|
||||
} as {
|
||||
id: string;
|
||||
cmd: string;
|
||||
has_files?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
cover?: string;
|
||||
},
|
||||
} as SelectedTestItem,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedItem(item: {
|
||||
id: string;
|
||||
cmd: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
}) {
|
||||
setSelectedItem(item: SelectedTestItem) {
|
||||
patchState(store, { selectedItem: item });
|
||||
},
|
||||
})),
|
||||
@@ -339,6 +335,183 @@ describe('withStalkerPlayer', () => {
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('temporary-link semantics', () => {
|
||||
const CHANNEL = {
|
||||
id: '10001',
|
||||
cmd: 'ffrt3 http://cdn.example/live/10001.m3u8',
|
||||
name: 'Static TV',
|
||||
o_name: 'Static TV',
|
||||
logo: 'static-tv.png',
|
||||
category_id: '1001',
|
||||
};
|
||||
|
||||
it('plays an unflagged ITV channel straight from its static cmd', async () => {
|
||||
store.setSelectedContentType('itv');
|
||||
|
||||
const playback = await store.resolveItvPlayback({
|
||||
...CHANNEL,
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/live/10001.m3u8'
|
||||
);
|
||||
expect(playback.isLive).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'mints a temporary link for an ITV channel with %s set',
|
||||
async (flag) => {
|
||||
store.setSelectedContentType('itv');
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://cdn.example/tmp/10001.m3u8' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveItvPlayback({
|
||||
...CHANNEL,
|
||||
[flag]: '1',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: CHANNEL.cmd,
|
||||
type: 'itv',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/tmp/10001.m3u8'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('mints a temporary link for a flagged radio station with a playable cmd', async () => {
|
||||
// Before the flags were read, a directly playable radio command
|
||||
// always bypassed create_link — a proxied station then played a
|
||||
// URL the portal never intended to serve.
|
||||
store.setSelectedContentType('radio');
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'http://cdn.example/tmp/jazz.mp3' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveRadioPlayback({
|
||||
id: 'radio-3',
|
||||
cmd: 'ifm https://stream.example/jazz.mp3',
|
||||
name: 'Jazz FM',
|
||||
o_name: 'Jazz FM',
|
||||
logo: 'jazz.png',
|
||||
category_id: '4001',
|
||||
use_http_tmp_link: '1',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
type: 'radio',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe('http://cdn.example/tmp/jazz.mp3');
|
||||
});
|
||||
|
||||
it('mints a link for a flagged VOD row with a directly playable cmd', async () => {
|
||||
// The VOD row reaches this method through
|
||||
// `buildStalkerSelectedVodItem`, a whitelist — if it ever drops
|
||||
// the flags again, this row reads as unflagged and the static
|
||||
// path silently plays the portal's non-final URL.
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedItem({
|
||||
id: '42',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
title: 'Flagged Movie',
|
||||
category_id: 'vod',
|
||||
use_http_tmp_link: '1',
|
||||
});
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'http://cdn.example/tmp/movie.mkv?tok=1' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Flagged Movie'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/tmp/movie.mkv?tok=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('plays an unflagged VOD row from its static cmd', async () => {
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedItem({
|
||||
id: '43',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
title: 'Static Movie',
|
||||
category_id: 'vod',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Static Movie'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe('http://cdn.example/movie.mkv');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['static', { use_http_tmp_link: '0' }, undefined],
|
||||
[
|
||||
'minted',
|
||||
{ use_http_tmp_link: '1' },
|
||||
{ js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=SECRET' } },
|
||||
],
|
||||
])(
|
||||
'stores the portal cmd, never the %s stream URL, in recently viewed',
|
||||
async (_label, flags, response) => {
|
||||
// A temporary link dies after ~5 s, so a replayed one is worse
|
||||
// than useless — the row has to keep the `cmd` and re-resolve.
|
||||
store.setSelectedContentType('itv');
|
||||
if (response) {
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce(response);
|
||||
}
|
||||
|
||||
await store.resolveItvPlayback({ ...CHANNEL, ...flags });
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
PLAYLIST._id,
|
||||
expect.objectContaining({
|
||||
id: '10001',
|
||||
cmd: CHANNEL.cmd,
|
||||
})
|
||||
);
|
||||
const [, persisted] =
|
||||
playlistService.addPortalRecentlyViewed.mock.calls[0];
|
||||
expect(JSON.stringify(persisted)).not.toContain('SECRET');
|
||||
expect(JSON.stringify(persisted)).not.toContain('/tmp/');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to radio playback resolved from the portal', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
|
||||
+38
-26
@@ -32,14 +32,16 @@ import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
hasStalkerLinkFlagEvidence,
|
||||
shouldResolveMovieFileId,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '../utils';
|
||||
|
||||
type StalkerPlayableItem = StalkerPortalItem & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
type StalkerPlayableItem = StalkerPortalItem &
|
||||
StalkerLinkFlagSource & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playback/link/player concern methods.
|
||||
@@ -189,6 +191,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd: cmdToUse,
|
||||
series: episodeNum,
|
||||
linkFlags: item,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -265,6 +268,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'itv',
|
||||
linkFlags: item,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -318,26 +322,32 @@ export function withStalkerPlayer() {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
let streamUrl = normalizeStalkerPlaybackCommand(item.cmd);
|
||||
if (
|
||||
!streamUrl.startsWith('http://') &&
|
||||
!streamUrl.startsWith('https://')
|
||||
) {
|
||||
streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'radio',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
// Radio has always skipped `create_link` for a directly
|
||||
// playable command. Handing the row to the shared decision
|
||||
// adds the flags, so a station the portal proxies now gets
|
||||
// its temporary link instead of a dead static URL — but a
|
||||
// row carrying no flags at all must keep the old rule
|
||||
// rather than start minting, or a portal whose radio
|
||||
// `create_link` never worked would lose playback it has.
|
||||
// ITV and VOD have no such history and stay conservative.
|
||||
const radioLinkFlags = hasStalkerLinkFlagEvidence(item)
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'radio',
|
||||
linkFlags: radioLinkFlags,
|
||||
}
|
||||
);
|
||||
|
||||
recordRecentlyViewed(
|
||||
item,
|
||||
@@ -384,7 +394,8 @@ export function withStalkerPlayer() {
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string,
|
||||
series?: number
|
||||
series?: number,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
) {
|
||||
return fetchStalkerPlaybackLink(requestDeps, {
|
||||
playlist: createRequestPlaylist(
|
||||
@@ -395,6 +406,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd,
|
||||
series,
|
||||
linkFlags,
|
||||
});
|
||||
},
|
||||
async getExpireDate() {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './stalker-collection-persistence.utils';
|
||||
export * from './stalker-link-semantics.utils';
|
||||
export * from './stalker-playback-command.utils';
|
||||
export * from './stalker-player-request.utils';
|
||||
export * from './stalker-request.utils';
|
||||
export * from './stalker-content-mappers';
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
isStalkerPortalFlagEnabled,
|
||||
requiresStalkerTemporaryLink,
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
} from './stalker-link-semantics.utils';
|
||||
|
||||
describe('stalker-link-semantics', () => {
|
||||
describe('isStalkerPortalFlagEnabled', () => {
|
||||
it.each([
|
||||
['1', true],
|
||||
[1, true],
|
||||
[true, true],
|
||||
['true', true],
|
||||
['0', false],
|
||||
[0, false],
|
||||
[false, false],
|
||||
['false', false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
[Number.NaN, false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(isStalkerPortalFlagEnabled(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresStalkerTemporaryLink', () => {
|
||||
it('is false for a row that sets neither flag', () => {
|
||||
expect(
|
||||
requiresStalkerTemporaryLink({
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the row carries no flags at all', () => {
|
||||
expect(requiresStalkerTemporaryLink({})).toBe(false);
|
||||
expect(requiresStalkerTemporaryLink(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'is true when %s is set',
|
||||
(flag) => {
|
||||
expect(requiresStalkerTemporaryLink({ [flag]: '1' })).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveStalkerStaticPlaybackUrl', () => {
|
||||
it('plays an unflagged absolute command and strips the solution prefix', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0', use_load_balancing: '0' },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('accepts a bare URL with no solution prefix', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'https://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('https://cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'defers to create_link when %s is set',
|
||||
(flag) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ [flag]: '1' },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
// The VOD has_files rewrite produces exactly this shape.
|
||||
['/media/file_42.mpg'],
|
||||
['?token=abc'],
|
||||
['ffrt4://ch/live/10001/index.m3u8'],
|
||||
['rtmp://cdn.example/live/42'],
|
||||
[''],
|
||||
[' '],
|
||||
])('defers to create_link for the unresolvable command %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://localhost/ch/1234_'],
|
||||
['http://127.0.0.1:8080/ch/1234_'],
|
||||
['http://0.0.0.0/ch/1234_'],
|
||||
['http://[::1]/ch/1234_'],
|
||||
])('defers to create_link for the portal-local address %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict when the caller has no row to read flags from', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
undefined,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
null,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict for a row carrying neither flag key', () => {
|
||||
// A Favorite persisted before these flags were carried was
|
||||
// stripped of them by `buildStalkerSelectedVodItem`'s whitelist,
|
||||
// so it is indistinguishable from a row the portal marked
|
||||
// unflagged. There is no migration for those rows, so absence has
|
||||
// to read as "no evidence" rather than "no temporary link".
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ id: '42', cmd: 'x' } as never,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: undefined, use_load_balancing: null },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http://127.0.0.2/ch/1234_'],
|
||||
['http://127.1.2.3:8080/ch/1234_'],
|
||||
['http://127.255.255.255/ch/1234_'],
|
||||
])('treats the whole 127.0.0.0/8 range as portal-local (%p)', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// `URL` keeps the brackets and normalizes the address, so these
|
||||
// reach the guard as `[::]`, `[::1]` and `[::ffff:7f00:1]`.
|
||||
['http://[::]/ch/1234_'],
|
||||
['http://[::1]/ch/1234_'],
|
||||
['http://[0:0:0:0:0:0:0:1]/ch/1234_'],
|
||||
['http://[::ffff:127.0.0.1]/ch/1234_'],
|
||||
['http://[::ffff:7f00:1]/ch/1234_'],
|
||||
['http://[::ffff:127.255.255.255]/ch/1234_'],
|
||||
['http://[::ffff:0.0.0.0]/ch/1234_'],
|
||||
])('treats the IPv6 portal-local form %p as local', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://localhost./ch/1234_'],
|
||||
['http://LocalHost./ch/1234_'],
|
||||
])('strips the DNS root dot before classifying %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HTTP://cdn.example/live/42.m3u8'],
|
||||
['ffrt3 HTTPS://cdn.example/live/42.m3u8'],
|
||||
['hTTp://cdn.example/live/42.m3u8'],
|
||||
])('accepts the case-insensitive scheme %p', (cmd) => {
|
||||
// RFC 3986 makes the scheme case-insensitive. A case-sensitive
|
||||
// test failed safe (it minted a link) but defeated the contract
|
||||
// for a portal that spells it this way.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toMatch(/cdn\.example\/live\/42\.m3u8$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http:///ch/1234_'],
|
||||
['ffrt3 https:///ch/1234_'],
|
||||
])('defers to create_link for the authority-less url %p', (cmd) => {
|
||||
// `new URL('http:///ch/1')` reports the host as `ch` — a malformed
|
||||
// command would otherwise reach the player as a nonsense address
|
||||
// rather than going to the portal to be resolved.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a routable IPv6 host', () => {
|
||||
// 2001:db8::1 is documentation space, but it is routable as far as
|
||||
// this guard is concerned — only local placeholders are rejected.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://[2001:db8::1]/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://[2001:db8::1]/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('keeps an IPv4-mapped address that is not loopback', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://[::ffff:203.0.113.7]/live/42.m3u8'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// RFC 6761 §6.3 reserves the whole `.localhost` suffix.
|
||||
['http://stream.localhost/ch/1234_'],
|
||||
['ffrt3 http://a.b.localhost/ch/1234_'],
|
||||
['http://stream.localhost./ch/1234_'],
|
||||
// Conventional /etc/hosts alias for 127.0.0.1.
|
||||
['http://localhost.localdomain/ch/1234_'],
|
||||
])('treats the localhost name %p as portal-local', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mistake a localhost-prefixed host for a localhost name', () => {
|
||||
// Only the SUFFIX is reserved — `localhost.cdn.example` is an
|
||||
// ordinary routable name and must keep playing statically.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://localhost.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://localhost.cdn.example/live/42.m3u8');
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://notlocalhost/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://notlocalhost/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('does not mistake a non-loopback 127-lookalike for loopback', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://127.0.0.1.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://127.0.0.1.cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('keeps a non-loopback host that merely looks local', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://localhost.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://localhost.cdn.example/live/42.m3u8');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
isPlayableHttpUrl,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
/**
|
||||
* The two catalog flags that decide whether a row needs a temporary link.
|
||||
*
|
||||
* Reference behaviour (portal `player.js`, mirrored by Kodi's pvr.stalker):
|
||||
* a client calls `create_link` only when the row asks for it — either because
|
||||
* the portal proxies the stream through a per-session temporary URL
|
||||
* (`use_http_tmp_link`) or because it picks a storage server per request
|
||||
* (`use_load_balancing`). Every other row plays the static `cmd` that
|
||||
* `get_all_channels` / `get_ordered_list` already returned.
|
||||
*
|
||||
* Portals send these as `'1'`/`'0'` strings, `1`/`0` numbers or booleans, so
|
||||
* the values arrive untyped.
|
||||
*/
|
||||
export interface StalkerLinkFlagSource {
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that can only mean "the portal itself". A `cmd` such as
|
||||
* `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, never an
|
||||
* address the set-top box could open, so it always needs resolving.
|
||||
*/
|
||||
const PORTAL_LOCAL_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
// Conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems.
|
||||
'localhost.localdomain',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'::',
|
||||
]);
|
||||
|
||||
/**
|
||||
* RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost`
|
||||
* for the loopback interface, and resolvers honour it — so `stream.localhost`
|
||||
* would reach the player's own machine.
|
||||
*/
|
||||
const LOCALHOST_SUFFIX = '.localhost';
|
||||
|
||||
/** IPv4 reserves all of `127.0.0.0/8` for loopback, not just `127.0.0.1`. */
|
||||
const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
|
||||
/** How `URL` normalizes an IPv4-mapped address: `::ffff:7f00:1`. */
|
||||
const IPV6_MAPPED_IPV4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
||||
|
||||
/**
|
||||
* Whether the host can only mean the machine resolving it — the portal when it
|
||||
* wrote the command, the player if we took it literally.
|
||||
*
|
||||
* `URL.hostname` keeps IPv6 in brackets and normalizes the address, so
|
||||
* `[0:0:0:0:0:0:0:1]` arrives as `[::1]` and an IPv4-mapped `127.0.0.1` as hex
|
||||
* (`[::ffff:7f00:1]`) — both are matched here rather than relying on the
|
||||
* portal to spell them the obvious way.
|
||||
*/
|
||||
function isPortalLocalHostname(hostname: string): boolean {
|
||||
// A trailing dot is the DNS root and resolves identically, but `URL` keeps
|
||||
// it for names (`localhost.`) while dropping it for IP literals — so an
|
||||
// exact-name check has to strip it or `http://localhost./ch/1_` reads as a
|
||||
// remote host.
|
||||
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
if (
|
||||
PORTAL_LOCAL_HOSTNAMES.has(host) ||
|
||||
host.endsWith(LOCALHOST_SUFFIX) ||
|
||||
IPV4_LOOPBACK.test(host)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dotted IPv4-mapped form, for any engine that does not rewrite it to hex.
|
||||
if (host.startsWith('::ffff:')) {
|
||||
const tail = host.slice('::ffff:'.length);
|
||||
if (IPV4_LOOPBACK.test(tail) || tail === '0.0.0.0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = IPV6_MAPPED_IPV4.exec(host);
|
||||
if (!mapped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The whole of 127.0.0.0/8 shares the 0x7f high byte (127.0.0.1 is
|
||||
// 7f00:0001); `::ffff:0:0` is the mapped unspecified address.
|
||||
const high = Number.parseInt(mapped[1], 16);
|
||||
const low = Number.parseInt(mapped[2], 16);
|
||||
return high >>> 8 === 0x7f || (high === 0 && low === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row can speak for itself about temporary links.
|
||||
*
|
||||
* A stock portal returns both flags on every ITV/VOD row, so their PRESENCE
|
||||
* is the provenance signal — and it is the only one available, because rows
|
||||
* persisted into Favorites/Recently Viewed before these flags were carried
|
||||
* were stripped of them by `buildStalkerSelectedVodItem`'s whitelist. Without
|
||||
* this check a legacy snapshot would be indistinguishable from a row the
|
||||
* portal genuinely marked unflagged, and would take the static path on a
|
||||
* command that may still need resolving. There is no migration or provenance
|
||||
* marker for those rows, so absence has to mean "no evidence", not "no".
|
||||
*/
|
||||
export function hasStalkerLinkFlagEvidence(
|
||||
source: StalkerLinkFlagSource | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
source?.use_http_tmp_link != null || source?.use_load_balancing != null
|
||||
);
|
||||
}
|
||||
|
||||
/** Truthiness for portal flags, which arrive as strings, numbers or booleans. */
|
||||
export function isStalkerPortalFlagEnabled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value !== 0;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized !== '0' && normalized !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row explicitly asks the client to mint a temporary link.
|
||||
*/
|
||||
export function requiresStalkerTemporaryLink(
|
||||
source: StalkerLinkFlagSource | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
isStalkerPortalFlagEnabled(source?.use_http_tmp_link) ||
|
||||
isStalkerPortalFlagEnabled(source?.use_load_balancing)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The playable URL for a row that does NOT need `create_link`, or `null` when
|
||||
* the portal has to resolve it.
|
||||
*
|
||||
* `null` is returned for every shape a client cannot resolve on its own, which
|
||||
* is deliberately wider than the flag check alone — the flags are the rule,
|
||||
* these guards only ever push a row back onto today's `create_link` path and
|
||||
* so cannot regress a portal that works now:
|
||||
*
|
||||
* - no row at all, or a row carrying neither flag key — no evidence, so no
|
||||
* verdict (see {@link hasStalkerLinkFlagEvidence}: a row persisted before
|
||||
* these flags were carried looks identical to one the portal marked
|
||||
* unflagged);
|
||||
* - either flag set — the portal asked for a temporary link;
|
||||
* - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command —
|
||||
* only `create_link` turns those into an address, and the VOD `has_files`
|
||||
* rewrite produces exactly the first shape;
|
||||
* - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL —
|
||||
* or an HTTP command with no authority (`http:///ch/1`), which `URL` would
|
||||
* quietly reinterpret as the host `ch`;
|
||||
* - a portal-local host — a placeholder only the portal could resolve (see
|
||||
* {@link isPortalLocalHostname}, which covers rather more than the obvious
|
||||
* `localhost`).
|
||||
*/
|
||||
export function resolveStalkerStaticPlaybackUrl(
|
||||
source: StalkerLinkFlagSource | null | undefined,
|
||||
cmd: string
|
||||
): string | null {
|
||||
if (
|
||||
!hasStalkerLinkFlagEvidence(source) ||
|
||||
requiresStalkerTemporaryLink(source)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = normalizeStalkerPlaybackCommand(cmd);
|
||||
if (!isPlayableHttpUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPortalLocalHostname(new URL(url).hostname.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
hasHttpScheme,
|
||||
isPlayableHttpUrl,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
const PORTAL = 'http://demo.example/stalker_portal/server/load.php';
|
||||
|
||||
describe('stalker-playback-command.utils', () => {
|
||||
describe('hasHttpScheme', () => {
|
||||
it.each([
|
||||
['http://a/b', true],
|
||||
['https://a/b', true],
|
||||
['HTTP://a/b', true],
|
||||
['HtTpS://a/b', true],
|
||||
['ffrt4://a/b', false],
|
||||
['rtmp://a/b', false],
|
||||
['/media/1.mpg', false],
|
||||
['', false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(hasHttpScheme(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlayableHttpUrl', () => {
|
||||
it.each([
|
||||
['http://cdn.example/a.ts', true],
|
||||
['HTTPS://cdn.example/a.ts', true],
|
||||
['http://[::1]/a.ts', true],
|
||||
// `new URL` reinterprets the first path segment as the host here.
|
||||
['http:///ch/1', false],
|
||||
['https:///ch/1', false],
|
||||
['ffrt4://ch/1', false],
|
||||
['/media/1.mpg', false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(isPlayableHttpUrl(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStalkerPlaybackCommand', () => {
|
||||
it('splits the solution prefix off a lowercase url', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 http://cdn.example/a.ts')
|
||||
).toBe('http://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('splits the solution prefix off an uppercase scheme too', () => {
|
||||
// RFC 3986 makes the scheme case-insensitive; a case-sensitive
|
||||
// test returned the whole `ffrt3 HTTP://…` string as if it were
|
||||
// the URL.
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 HTTP://cdn.example/a.ts')
|
||||
).toBe('HTTP://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('leaves a bare command untouched', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt4://ch/live/1/index.m3u8')
|
||||
).toBe('ffrt4://ch/live/1/index.m3u8');
|
||||
});
|
||||
|
||||
it('keeps relative and query-only replies', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffmpeg /media/file_1.mpg')
|
||||
).toBe('/media/file_1.mpg');
|
||||
expect(normalizeStalkerPlaybackCommand('ffmpeg ?token=abc')).toBe(
|
||||
'?token=abc'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStalkerPlaybackUrl', () => {
|
||||
it('returns an absolute reply as-is regardless of scheme case', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'ffmpeg HTTPS://cdn.example/a.ts'
|
||||
)
|
||||
).toBe('HTTPS://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('resolves a relative reply against the installation base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'/media/video_7.mpg'
|
||||
)
|
||||
).toBe('http://demo.example/stalker_portal/media/video_7.mpg');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to an uppercase-scheme command', () => {
|
||||
// The original `cmd` is already an address, so the query belongs
|
||||
// on it rather than on the portal base.
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'auto HTTP://cdn.example/live/9.m3u8',
|
||||
'?token=xyz'
|
||||
)
|
||||
).toBe('HTTP://cdn.example/live/9.m3u8?token=xyz');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to a relative command via the base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '?token=xyz')
|
||||
).toBe('http://demo.example/stalker_portal/ch/9?token=xyz');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty reply', () => {
|
||||
expect(resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure `cmd` handling shared by every Stalker playback path.
|
||||
*
|
||||
* Kept apart from the request helpers so the link-semantics decision (which
|
||||
* needs the normalizer) and the request helpers (which need the decision) do
|
||||
* not import each other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* RFC 3986 makes the scheme case-insensitive and portals are inconsistent
|
||||
* about it, so every scheme test here goes through this rather than
|
||||
* `startsWith('http://')` — which would send `HTTP://…` down the
|
||||
* "not a URL" branch of whichever check it hit.
|
||||
*/
|
||||
const HTTP_SCHEME = /^https?:\/\//i;
|
||||
|
||||
/**
|
||||
* The authority must be non-empty: `http:///ch/1` silently reinterprets the
|
||||
* first path segment as the host (`URL` reports `ch`), so a malformed command
|
||||
* would otherwise be handed to the player as a nonsense address instead of
|
||||
* going to the portal to be resolved.
|
||||
*/
|
||||
const HTTP_URL_WITH_AUTHORITY = /^https?:\/\/[^/]/i;
|
||||
|
||||
export function hasHttpScheme(value: string): boolean {
|
||||
return HTTP_SCHEME.test(value);
|
||||
}
|
||||
|
||||
/** A command that is usable as an address on its own. */
|
||||
export function isPlayableHttpUrl(value: string): boolean {
|
||||
return HTTP_URL_WITH_AUTHORITY.test(value);
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
hasHttpScheme(candidate) ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveStalkerPlaybackUrl(
|
||||
portalUrl: string,
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
const url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (hasHttpScheme(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
// The installation base is the endpoint path MINUS the API suffix
|
||||
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
|
||||
// discovery can persist arbitrary nested installations
|
||||
// (`/cp/server/load.php`), so a fixed segment allowlist would
|
||||
// resolve `/media/...` against the wrong root. The legacy marker
|
||||
// segments stay as the fallback for URLs that carry neither suffix.
|
||||
const endpointPath = portalUrlObj.pathname;
|
||||
let basePath = '';
|
||||
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
|
||||
if (apiSuffix.test(endpointPath)) {
|
||||
basePath = endpointPath.replace(apiSuffix, '');
|
||||
} else {
|
||||
const pathParts = endpointPath.split('/');
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
if (
|
||||
pathParts[index] === 'stalker_portal' ||
|
||||
pathParts[index] === 'c' ||
|
||||
pathParts[index] === 'portal'
|
||||
) {
|
||||
basePath = '/' + pathParts.slice(1, index + 1).join('/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (hasHttpScheme(normalizedCmd)) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${portalUrlObj.origin}${basePath}${url}`;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function shouldResolveMovieFileId(
|
||||
item: { has_files?: unknown } | null | undefined,
|
||||
cmd: string
|
||||
): boolean {
|
||||
return (
|
||||
item?.has_files !== undefined &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_')
|
||||
);
|
||||
}
|
||||
+284
-2
@@ -1,10 +1,10 @@
|
||||
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { shouldResolveMovieFileId } from './stalker-playback-command.utils';
|
||||
import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
shouldResolveMovieFileId,
|
||||
} from './stalker-player-request.utils';
|
||||
|
||||
const PLAYLIST = {
|
||||
@@ -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 }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -180,6 +184,284 @@ describe('stalker-player-request.utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('temporary-link semantics', () => {
|
||||
const deps = () => ({
|
||||
dataService: dataService as never,
|
||||
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;
|
||||
(stalkerSession.ensureToken as jest.Mock).mockResolvedValue({
|
||||
token: 'TOKEN-WARM',
|
||||
});
|
||||
|
||||
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('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 without contacting the portal', async () => {
|
||||
// A CDN stream never needs the portal session, so it must not be
|
||||
// pushed onto the request path — and must not wait on a handshake
|
||||
// either: that is up to 15 s per request against a portal that may
|
||||
// be offline while the CDN is reachable.
|
||||
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(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
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
|
||||
// configuration a repair has already proven broken would strand
|
||||
// the session.
|
||||
const stale = {
|
||||
...PLAYLIST,
|
||||
portalUrl: 'http://demo.example/portal.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
const repaired = {
|
||||
...stale,
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
} as PlaylistMeta;
|
||||
// Portal-owned URL: only those reach the handshake now, since a
|
||||
// foreign host never needs the session.
|
||||
(stalkerSession.ensureToken as jest.Mock).mockResolvedValue({
|
||||
token: 'TOKEN-REPAIRED',
|
||||
});
|
||||
|
||||
await fetchStalkerPlaybackLink(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
portalRepair: {
|
||||
applyOverride: jest.fn().mockReturnValue(repaired),
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(false),
|
||||
repairPortal: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
playlist: stale,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://demo.example/live/42.m3u8',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ portalUrl: repaired.portalUrl })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the handshake for a simple portal', async () => {
|
||||
// The command has to be PORTAL-OWNED, or the foreign-host early
|
||||
// return would skip the handshake by itself and this would pass
|
||||
// without saying anything about portal mode. `PLAYLIST` is a
|
||||
// simple portal, so the skip here can only come from the mode.
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://demo.example/live/42.m3u8',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://demo.example/live/42.m3u8');
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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: {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta,
|
||||
selectedContentType: 'vod',
|
||||
cmd: 'ffrt3 http://demo.example/movies/1.mkv',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
linkFlags: {
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/live/42.m3u8');
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'mints a temporary link when %s is set',
|
||||
async (flag) => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/42.m3u8?tok=1' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
linkFlags: { [flag]: '1' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/tmp/42.m3u8?tok=1');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('mints a link when the caller supplies no flags', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/42.m3u8' },
|
||||
});
|
||||
|
||||
await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always mints a link for an episode, whose cmd addresses the series', async () => {
|
||||
// `series` selects the episode server-side, so the parent row's
|
||||
// static cmd is not an answer even when it is unflagged.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/ep3.m3u8' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'series',
|
||||
cmd: 'ffrt3 http://cdn.example/series/7.m3u8',
|
||||
series: 3,
|
||||
linkFlags: {
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/tmp/ep3.m3u8');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ series: '3' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('still mints a link for a relative unflagged VOD command', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '/media/video_77.mpg' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'vod',
|
||||
cmd: '/media/file_42.mpg',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe(
|
||||
'http://demo.example/stalker_portal/media/video_77.mpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a localized expire date string from account info', async () => {
|
||||
const expireDate = 1_713_139_200;
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
|
||||
+60
-98
@@ -1,5 +1,6 @@
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
isStalkerStreamCredentialSafe,
|
||||
PlaylistMeta,
|
||||
StalkerPortalActions,
|
||||
StalkerPortalItem,
|
||||
@@ -8,6 +9,12 @@ import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import {
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
type StalkerLinkFlagSource,
|
||||
} from './stalker-link-semantics.utils';
|
||||
import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils';
|
||||
import {
|
||||
ensureStalkerSession,
|
||||
executeStalkerRequest,
|
||||
type StalkerPortalRepairApi,
|
||||
} from './stalker-request.utils';
|
||||
@@ -29,108 +36,13 @@ export interface StalkerPlayerRequestDeps {
|
||||
portalRepair?: StalkerPortalRepairApi;
|
||||
}
|
||||
|
||||
export interface StalkerPlayableItemLike extends StalkerPortalItem {
|
||||
export interface StalkerPlayableItemLike
|
||||
extends StalkerPortalItem,
|
||||
StalkerLinkFlagSource {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://') ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveStalkerPlaybackUrl(
|
||||
portalUrl: string,
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
const url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
// The installation base is the endpoint path MINUS the API suffix
|
||||
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
|
||||
// discovery can persist arbitrary nested installations
|
||||
// (`/cp/server/load.php`), so a fixed segment allowlist would
|
||||
// resolve `/media/...` against the wrong root. The legacy marker
|
||||
// segments stay as the fallback for URLs that carry neither suffix.
|
||||
const endpointPath = portalUrlObj.pathname;
|
||||
let basePath = '';
|
||||
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
|
||||
if (apiSuffix.test(endpointPath)) {
|
||||
basePath = endpointPath.replace(apiSuffix, '');
|
||||
} else {
|
||||
const pathParts = endpointPath.split('/');
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
if (
|
||||
pathParts[index] === 'stalker_portal' ||
|
||||
pathParts[index] === 'c' ||
|
||||
pathParts[index] === 'portal'
|
||||
) {
|
||||
basePath = '/' + pathParts.slice(1, index + 1).join('/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (
|
||||
normalizedCmd.startsWith('http://') ||
|
||||
normalizedCmd.startsWith('https://')
|
||||
) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${portalUrlObj.origin}${basePath}${url}`;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function shouldResolveMovieFileId(
|
||||
item: Pick<StalkerPlayableItemLike, 'has_files'> | null | undefined,
|
||||
cmd: string
|
||||
): boolean {
|
||||
return (
|
||||
item?.has_files !== undefined &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_')
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchStalkerPlaybackLink(
|
||||
deps: StalkerPlayerRequestDeps,
|
||||
options: {
|
||||
@@ -139,8 +51,58 @@ export async function fetchStalkerPlaybackLink(
|
||||
cmd: string;
|
||||
series?: number;
|
||||
forcedContentType?: StalkerContentType;
|
||||
/**
|
||||
* The catalog row this `cmd` came from. Without it every playback
|
||||
* mints a temporary link; with it, rows that set neither
|
||||
* `use_http_tmp_link` nor `use_load_balancing` play their static
|
||||
* `cmd` and never touch the portal.
|
||||
*/
|
||||
linkFlags?: StalkerLinkFlagSource | null;
|
||||
}
|
||||
): Promise<string> {
|
||||
// An episode is selected server-side by the `series` parameter, so a
|
||||
// series request has no static answer even when the parent row is
|
||||
// unflagged — the static `cmd` addresses the series, not the episode.
|
||||
if (options.series === undefined) {
|
||||
const staticUrl = resolveStalkerStaticPlaybackUrl(
|
||||
options.linkFlags,
|
||||
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.
|
||||
// Classify BEFORE authenticating. A stream on a foreign host never
|
||||
// needs the portal session, and warming it anyway would block
|
||||
// playback behind a handshake worth up to 15 s per request against
|
||||
// a slow or offline portal (`stalker.events.ts`) for a result that
|
||||
// is then discarded — the CDN is reachable even when the portal is
|
||||
// not.
|
||||
if (
|
||||
!isStalkerStreamCredentialSafe(
|
||||
options.playlist.portalUrl ?? '',
|
||||
staticUrl
|
||||
)
|
||||
) {
|
||||
return staticUrl;
|
||||
}
|
||||
|
||||
// Portal-owned: it may be gated on the Bearer token, and minting
|
||||
// the link used to be what established the session. Without a
|
||||
// usable one, serving this would be serving a known 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 (await ensureStalkerSession(deps, options.playlist)) {
|
||||
return staticUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentType =
|
||||
options.forcedContentType ?? options.selectedContentType;
|
||||
const response = await executeStalkerRequest<StalkerPlayerResponse>(
|
||||
|
||||
@@ -38,6 +38,64 @@ 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.
|
||||
*
|
||||
* 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<boolean> {
|
||||
if (!playlist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The repair override is applied here for the same reason
|
||||
// `executeStalkerRequest` applies it on its first line: a completed repair
|
||||
// may have moved the endpoint or the mode while the caller still holds the
|
||||
// pre-repair row, and handshaking against the configuration a repair has
|
||||
// already proven broken would strand the session.
|
||||
const effective = deps.portalRepair
|
||||
? 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 true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { token } = await deps.stalkerSession.ensureToken(
|
||||
toStalkerSessionPlaylist(effective)
|
||||
);
|
||||
return Boolean(token);
|
||||
} catch (error) {
|
||||
logger?.warn('Could not establish the Stalker session', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one Stalker request according to the playlist's portal mode:
|
||||
* full portals go through the authenticated session (handshake + Bearer
|
||||
|
||||
+8
-2
@@ -262,8 +262,14 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
playlist: this.catalog.playlist(),
|
||||
downloadsService: this.downloadsService,
|
||||
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
|
||||
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) =>
|
||||
this.catalog.fetchLinkToPlay(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
cmd,
|
||||
undefined,
|
||||
linkFlags
|
||||
),
|
||||
language:
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
|
||||
+102
@@ -120,4 +120,106 @@ describe('startStalkerVodDownload', () => {
|
||||
expect(snapshot).not.toHaveProperty('rating');
|
||||
expect(snapshot).not.toHaveProperty('tmdbId');
|
||||
});
|
||||
|
||||
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.
|
||||
const startDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
const fetchLinkToPlay = jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://cdn.example.test/movie.mpg');
|
||||
const data = {
|
||||
id: '42',
|
||||
cmd: 'ffrt3 https://cdn.example.test/movie.mpg',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
info: { name: 'Static 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',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
},
|
||||
downloadsService: { startDownload },
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay,
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchLinkToPlay).toHaveBeenCalledWith(
|
||||
'https://stalker.example.test',
|
||||
'00:1A:79:12:34:56',
|
||||
data.cmd,
|
||||
expect.objectContaining({
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
})
|
||||
);
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://cdn.example.test/movie.mpg',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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,7 +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.
|
||||
@@ -25,6 +28,9 @@ import {
|
||||
export interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
/** Temporary-link flags — see {@link StalkerLinkFlagSource}. */
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
title?: string;
|
||||
category_id?: string | number;
|
||||
info?: {
|
||||
@@ -61,7 +67,8 @@ export interface StalkerVodDownloadDeps {
|
||||
fetchLinkToPlay: (
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
) => Promise<string | null>;
|
||||
language?: string;
|
||||
}
|
||||
@@ -124,10 +131,30 @@ export async function startStalkerVodDownload(
|
||||
firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown';
|
||||
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
|
||||
|
||||
// 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
|
||||
cmdToUse,
|
||||
staticUrlIsSelfAuthenticating ? itemData : undefined
|
||||
);
|
||||
if (!url) {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
isStalkerSeriesFlag,
|
||||
StalkerLinkFlagSource,
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -251,9 +252,17 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
async fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
series?: number,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
): Promise<string> {
|
||||
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
|
||||
return this.stalkerStore.fetchLinkToPlay(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
cmd,
|
||||
series,
|
||||
linkFlags
|
||||
);
|
||||
}
|
||||
|
||||
resolveVodPlayback(
|
||||
|
||||
@@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a terminal DNS root dot as the same host', () => {
|
||||
// `portal.example.` and `portal.example` resolve identically, but
|
||||
// `URL` preserves the dot — comparing literally classified a
|
||||
// portal-owned stream as third-party and stripped its credentials.
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example./live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
'http://portal.example./portal.php',
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects a different host that merely shares a suffix', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://evil.portal.example./live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on missing or unparseable URLs', () => {
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* credentials to a third party, and an https→http downgrade would replay a
|
||||
* TLS-obtained session in cleartext — both stay credential-free.
|
||||
*/
|
||||
/**
|
||||
* A terminal dot is the DNS root: `portal.example.` and `portal.example`
|
||||
* resolve to the same host, but `URL` preserves the dot, so a literal
|
||||
* comparison would call them different origins — classifying a portal-owned
|
||||
* stream as third-party and stripping the credentials it needs.
|
||||
*/
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function isStalkerStreamCredentialSafe(
|
||||
portalUrl: string | undefined | null,
|
||||
streamUrl: string | undefined | null
|
||||
@@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
|
||||
if (
|
||||
normalizeHostname(portal.hostname) !==
|
||||
normalizeHostname(stream.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user