From 2adf687b9b53783d1d876dceb9e9457ec3a29ff8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 08:51:31 +0200 Subject: [PATCH] fix(stalker): only mint a temporary link when the row asks for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_link` ran on every Stalker playback. The reference client — the portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise it plays the static `cmd` that `get_all_channels` / `get_ordered_list` already returned. Neither flag was read anywhere in the codebase, so every channel paid a round trip and gained a failure point the reference client does not have. One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path, and by `StreamResolverService` for Favorites/Recently Viewed. Its guards are deliberately wider than the flags alone and can only route a row back onto the `create_link` path: no row to read flags from, a relative or query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host. An episode always mints, since `series` selects it server-side. Radio joins the same decision, so a station the portal proxies now gets its link instead of playing a URL the portal never meant to serve. Temporary links live ~5 s, so the audit that came with this: favorites and recently-viewed persist the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path — none replay a resolved URL. Downloads are the documented exception, and honouring the flags shrinks even that, since an unflagged movie now yields a permanent URL that survives retry. `forced_storage` and `play_token` stay unwired, with the reasoning recorded in the docs rather than left ambiguous. The mock's ITV/radio rows now carry both flags, and the new `static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows with a playable command so the e2e can assert that NO `create_link` request reaches the portal — verified to fail when the change is reverted, with a companion test proving the recorder sees a link when one is due. Co-Authored-By: Claude Opus 5 --- .changes/stalker-static-channel-links.md | 10 ++ CLAUDE.md | 17 +++ .../stalker-playback-context.service.spec.ts | 43 ++++++ apps/stalker-mock-server/README.md | 8 ++ .../src/app/data-generator.ts | 40 +++++- apps/stalker-mock-server/src/app/scenarios.ts | 20 +++ apps/web-e2e/src/stalker.e2e.ts | 64 +++++++++ docs/architecture/stalker-mock-server.md | 13 ++ docs/architecture/stalker-portal.md | 109 +++++++++++++++ .../stream-resolver.service.spec.ts | 85 ++++++++++++ .../lib/collection/stream-resolver.service.ts | 25 ++-- .../util/src/lib/portal-catalog-facade.ts | 9 +- .../with-stalker-player.feature.spec.ts | 122 +++++++++++++++++ .../features/with-stalker-player.feature.ts | 48 ++++--- .../data-access/src/lib/stores/utils/index.ts | 2 + .../stalker-link-semantics.utils.spec.ts | 129 ++++++++++++++++++ .../utils/stalker-link-semantics.utils.ts | 111 +++++++++++++++ .../utils/stalker-playback-command.utils.ts | 104 ++++++++++++++ .../stalker-player-request.utils.spec.ts | 108 ++++++++++++++- .../utils/stalker-player-request.utils.ts | 126 ++++------------- .../stalker-catalog-detail.component.ts | 10 +- .../stalker-vod-download.spec.ts | 51 +++++++ .../stalker-vod-download.ts | 13 +- .../src/lib/stalker-catalog-facade.service.ts | 13 +- 24 files changed, 1139 insertions(+), 141 deletions(-) create mode 100644 .changes/stalker-static-channel-links.md create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts 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 cb66a3e82..758333a80 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..a0bff3cf3 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -65,6 +65,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 +127,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 +689,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 4b5b9d0a9..b79e511a2 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -305,6 +305,115 @@ 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. This is NOT the same as a row that carries none — a portal too old to send them is genuinely announcing "no temporary link". | +| 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. | +| Loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) | `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. + +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()`. +- Favorites and Recently Viewed — `StreamResolverService.resolveStalker()` + reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`). + An item with no row snapshot gets no verdict, except radio, which keeps its + long-standing "directly usable command plays as-is" behaviour. + +### 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: a movie whose row needs +no temporary link now yields a permanent URL that survives retry. A movie that +genuinely needs one still stores a link that is dead by the time retry runs. +Fixing that needs the `cmd` on the download row plus a re-resolution step +before retry/resume, which is a schema change and is deliberately out of scope +here. + +### `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 + +- `stalker-link-semantics.utils.spec.ts` — the decision table above. +- `stalker-player-request.utils.spec.ts` — static short-circuit, both flags, + the `series` exception, relative VOD commands. +- `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. +- `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 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..cb1766263 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 @@ -808,6 +808,91 @@ 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('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..9e5107a15 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 @@ -22,10 +22,11 @@ import { executeStalkerRequest, getStalkerPortalOrigin, isCrossOriginStalkerStream, - normalizeStalkerPlaybackCommand, resolveStalkerPlaybackUrl, + resolveStalkerStaticPlaybackUrl, StalkerPortalRepairService, StalkerSessionService, + type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; @@ -333,14 +334,26 @@ export class StreamResolverService { const portalUrl = item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? ''; - const normalizedCmd = normalizeStalkerPlaybackCommand( + // 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 linkFlags = + (item.stalkerItem as StalkerLinkFlagSource | undefined) ?? + (item.radio === 'true' ? {} : undefined); + const staticUrl = resolveStalkerStaticPlaybackUrl( + linkFlags, item.stalkerCmd ?? '' ); - if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) { + if (staticUrl) { return this.buildStalkerPlayback(item, playlist, { macAddress, portalUrl, - streamUrl: normalizedCmd, + streamUrl: staticUrl, + isLive: item.radio === 'true' ? undefined : true, }); } @@ -1168,10 +1181,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..a2277cd54 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,17 @@ 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. + */ 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/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..4424e480f 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 @@ -339,6 +339,128 @@ 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.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..9396950b0 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, + resolveStalkerStaticPlaybackUrl, 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,22 +322,20 @@ 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', - } - ); - } + // Radio already skipped `create_link` for a directly + // playable command; going through the shared decision adds + // the flags, so a station the portal proxies now gets its + // temporary link instead of a dead static URL. + const streamUrl = + resolveStalkerStaticPlaybackUrl(item, item.cmd) ?? + (await fetchStalkerPlaybackLink(requestDeps, { + playlist, + selectedContentType: + storeState.selectedContentType(), + cmd: item.cmd, + forcedContentType: 'radio', + linkFlags: item, + })); if (!streamUrl) { throw new Error('nothing_to_play'); @@ -384,7 +386,8 @@ export function withStalkerPlayer() { portalUrl: string, macAddress: string, cmd: string, - series?: number + series?: number, + linkFlags?: StalkerLinkFlagSource | null ) { return fetchStalkerPlaybackLink(requestDeps, { playlist: createRequestPlaylist( @@ -395,6 +398,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..e29cd5d06 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts @@ -0,0 +1,129 @@ +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( + {}, + '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({}, 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({}, cmd)).toBeNull(); + }); + + it('gives no verdict when the caller has no row to read flags from', () => { + // Distinct from a row that simply carries no flags: the caller + // cannot vouch for the row, so the portal decides. + expect( + resolveStalkerStaticPlaybackUrl( + undefined, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl( + null, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + }); + + it('keeps a non-loopback host that merely looks local', () => { + expect( + resolveStalkerStaticPlaybackUrl( + {}, + '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..de3fb781b --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts @@ -0,0 +1,111 @@ +import { 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', + '127.0.0.1', + '0.0.0.0', + '::1', + '[::1]', +]); + +/** 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 — a caller that cannot show the flags gets no verdict, which + * is not the same as a row that carries none (a portal too old to send them + * is genuinely announcing "no temporary link"); + * - 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; + * - a loopback host — a portal-side placeholder (see + * {@link PORTAL_LOCAL_HOSTNAMES}). + */ +export function resolveStalkerStaticPlaybackUrl( + source: StalkerLinkFlagSource | null | undefined, + cmd: string +): string | null { + if (!source || requiresStalkerTemporaryLink(source)) { + return null; + } + + const url = normalizeStalkerPlaybackCommand(cmd); + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return null; + } + + try { + const hostname = new URL(url).hostname.toLowerCase(); + if (PORTAL_LOCAL_HOSTNAMES.has(hostname)) { + return null; + } + } catch { + return null; + } + + return url; +} 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..644c5b837 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ + +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: { 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..d18c01f93 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 = { @@ -180,6 +180,112 @@ describe('stalker-player-request.utils', () => { ); }); + describe('temporary-link semantics', () => { + const deps = () => ({ + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }); + + 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..c49f0411e 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 @@ -7,6 +7,11 @@ import { 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 { executeStalkerRequest, type StalkerPortalRepairApi, @@ -29,108 +34,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 +49,28 @@ 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) { + return staticUrl; + } + } + const contentType = options.forcedContentType ?? options.selectedContentType; const response = await executeStalkerRequest( 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..2611a6ab9 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,55 @@ describe('startStalkerVodDownload', () => { expect(snapshot).not.toHaveProperty('rating'); expect(snapshot).not.toHaveProperty('tmdbId'); }); + + it('hands the movie row on so an unflagged one can yield 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', + }) + ); + }); }); 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..e2f4c285d 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,6 +10,7 @@ type StalkerVodDetailsItem = Extract; import { normalizeStalkerEntityId, normalizeStalkerEntityIdAsNumber, + type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; /** @@ -25,6 +26,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 +65,8 @@ export interface StalkerVodDownloadDeps { fetchLinkToPlay: ( portalUrl: string, macAddress: string, - cmd: string + cmd: string, + linkFlags?: StalkerLinkFlagSource | null ) => Promise; language?: string; } @@ -124,10 +129,14 @@ 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. const url = await deps.fetchLinkToPlay( playlist.portalUrl, playlist.macAddress, - cmdToUse + cmdToUse, + itemData ); 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(