diff --git a/.changes/stalker-static-channel-links.md b/.changes/stalker-static-channel-links.md new file mode 100644 index 000000000..7b38e1328 --- /dev/null +++ b/.changes/stalker-static-channel-links.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index cce81de06..a98df166d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts index 00f3afb4a..588f76daa 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -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}`); + }); + }); }); diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index cc4138ba6..fa72541fc 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -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` | | `` | **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 | diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index dbc8cbaf5..29d8a73b0 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -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 + // ` ` 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', }; }); } diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index fe5bff591..74a8bd8e3 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -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 = { 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, + }, }; /** diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 7b810053d..07597d02b 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 { + 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, }) => { diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index ae4bcf58c..2c193f90d 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -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 `/assets/marketing/poster/.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`. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c53a1c0d5..bbc78ac74 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 ` when a diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index da145fc93..5d10d789b 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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) + ); + + 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) + ); + + 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) + ); + // 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) + ); + + 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) + ); + 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) + ); + 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) + ); + 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({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index c1d726560..2d265e795 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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(); @@ -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 { + 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. diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index 62101dd08..b70d3b184 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -87,10 +87,22 @@ export interface StalkerPortalCatalogFacade< addToFavorites(item: Record, onDone?: () => void): void; removeFromFavorites(favoriteId: string, onDone?: () => void): void; fetchMovieFileId(itemId: string): Promise; + /** + * `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; resolveVodPlayback( cmd?: string, diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts index 621d28344..d5027eed6 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts @@ -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; } /** diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index cda2d7219..8ac4bbda2 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index aca6cd3c2..398228470 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 8e2f5f367..e42b528b9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 37b0d9e98..7d43ce4c1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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() { diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts index e6dd2d051..1553b11cf 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts @@ -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'; diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts new file mode 100644 index 000000000..db1ffbf78 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts @@ -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'); + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts new file mode 100644 index 000000000..d06d7fbc4 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts @@ -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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts new file mode 100644 index 000000000..eec784746 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts @@ -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(''); + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts new file mode 100644 index 000000000..53487d4e6 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts @@ -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_') + ); +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts index 38e94137f..302f33ce5 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts @@ -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, unknown[]>; }; - let stalkerSession: Pick; + 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({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts index a5649fa03..8739edd27 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts @@ -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 | 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 { + // 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( diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index f07f4b622..7659936f6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -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 { + 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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index ded5cb3c3..80c0f77a3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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 || diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts index 0fc8e061d..b0749ef81 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts @@ -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', + }) + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts index 1d571bba0..e677d1d62 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -10,7 +10,10 @@ type StalkerVodDetailsItem = Extract; 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; 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 4a9188a27..bb9863cd4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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 { - return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd); + return this.stalkerStore.fetchLinkToPlay( + portalUrl, + macAddress, + cmd, + series, + linkFlags + ); } resolveVodPlayback( diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts index 57a9343cc..a758c289f 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts @@ -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); diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts index ec8848748..d20428fcd 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts @@ -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; }