diff --git a/.changes/stalker-auth-lifecycle.md b/.changes/stalker-auth-lifecycle.md new file mode 100644 index 000000000..3b62dc566 --- /dev/null +++ b/.changes/stalker-auth-lifecycle.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker portals that ask for a login and password now work: the import dialog +gained username/password fields and the app completes the portal's do_auth +step. When a portal refuses access, its own explanation is shown instead of a +generic error, saved sessions resume without re-authenticating, and the +keep-alive ping follows the portal's cadence. 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..39352c198 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 @@ -1094,6 +1111,16 @@ engine` (restart required) or - Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). - Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read. +**Stalker Session Authentication**: + +- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`. +- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. +- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. +- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. +- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 84b64d985..056cc73a1 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -6,6 +6,8 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; import { + classifyStalkerAuthFailureBody, + createStalkerAuthFailureMarker, PortalDebugEvent, STALKER_REQUEST, buildStalkerIdentityRequestContext, @@ -111,6 +113,18 @@ ipcMain.handle( throw httpError; } + // The portal answers auth failures with HTTP 200 and a plain + // text/html body ("Authorization failed.", "Access denied.", + // "Unauthorized request."). Classify them here so the renderer + // receives a structured marker instead of pattern-matching raw + // response text. + const authFailureBody = classifyStalkerAuthFailureBody( + response.data + ); + const responseData = authFailureBody + ? createStalkerAuthFailureMarker(authFailureBody) + : response.data; + // Return the response data if ( params.action === 'create_link' && @@ -136,12 +150,12 @@ ipcMain.handle( durationMs: Date.now() - startedAt, status: 'success', request: debugRequest, - response: response.data, + response: responseData, }; emitPortalDebugEvent(debugEvent); } - return response.data; + return responseData; } catch (error) { if (payload.requestId) { const debugEvent: PortalDebugEvent = { 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..ed222f3ce 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -82,9 +82,17 @@ actually get wrong: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `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:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) | +| `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/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts index 37d551acd..f33f57b58 100644 --- a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler'; /** * Handler-level coverage for the authentication actions. * - * The e2e suite drives the app against this server, but the login-required - * flow cannot be reached from the client yet (its `do_auth` path is dormant - * and sends empty credentials), so the scenario is pinned down here rather - * than only asserted in prose. + * The e2e suite drives the app through this flow end to end; these handler + * tests pin the SERVER half of the contract independently, so a client-side + * regression and a mock-side one cannot mask each other. * * Handlers are called directly instead of through the dispatcher: the * dispatcher pulls in every content handler and therefore the faker-based @@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => { })['token'] as string; expect(token).toMatch(/^[0-9A-F]{32}$/); - // The app sends auth_second_step=1 on its very first profile request, - // so that parameter alone must not satisfy the scenario. + // A client could mislabel its first profile as the second auth step; + // that parameter alone must not satisfy the scenario. expect( invoke( handleGetProfile, diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts index 463fb8466..ef5ff3b95 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -33,10 +33,11 @@ export function handleGetProfile( return; } - // Gate on the actual do_auth completion, not on auth_second_step: the app - // sends auth_second_step=1 on its very first get_profile, so a parameter - // check would let the login-required flow be bypassed without ever - // exercising status 2 -> do_auth -> profile retry. + // Gate on the actual do_auth completion, not on auth_second_step. The + // client now sends `auth_second_step=0` first and `1` only on the retry + // after do_auth, so a parameter check would happen to work — but it would + // also silently pass for any client that mislabels its first profile, + // which is exactly the protocol mistake this scenario exists to catch. if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { res.json({ js: { 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..15d236808 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -38,6 +38,21 @@ 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. + * + * Most tests here tolerate that reset anyway — they re-authenticate, or assert + * that a NEW token appears. The token-reuse test cannot: its assertion IS that + * the portal session survives, so it uses per-project MACs held outside + * `OWNED_MACS`. See `AUTH_REUSE_MACS`. */ test.describe.configure({ mode: 'serial' }); @@ -65,6 +80,20 @@ 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'; + +/** + * Login-required scenario MAC — get_profile answers status 2 until do_auth + * completes with non-empty credentials (empty credentials return {js:false}, + * as the operator billing script would). + */ +const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08'; + /** * 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 @@ -73,6 +102,25 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; */ const AUTH_FLOW_MAC = '00:1A:79:AD:00:01'; const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03'; + +/** + * The token-reuse test needs the portal session to SURVIVE untouched between + * its import and its reload — that persistence is the whole assertion. Every + * other test here is reset-tolerant (they either re-authenticate or assert a + * new token), so they can share `OWNED_MACS`, which each `beforeEach` clears. + * + * `mode: 'serial'` only serializes within one project; the browser projects + * still run concurrently, so a sibling project's reset would rotate this + * session mid-test. Hence one MAC per project, and deliberately NOT in + * `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is + * harmless — the import negotiates and adopts its own token first. + */ +const AUTH_REUSE_MACS: Record = { + chromium: '00:1A:79:AD:01:01', + firefox: '00:1A:79:AD:01:02', + webkit: '00:1A:79:AD:01:03', +}; +const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04'; /** * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for * it, so no token is ever adopted and content requests fail permanently. @@ -120,6 +168,8 @@ const OWNED_MACS = [ MINIMAL_MAC, EMBEDDED_SERIES_MAC, LEGACY_PAGINATION_MAC, + STATIC_CMD_MAC, + LOGIN_REQUIRED_MAC, AUTH_FLOW_MAC, AUTH_REAUTH_MAC, AUTH_REJECTED_MAC, @@ -194,9 +244,21 @@ async function addStalkerPortal( */ async function addFullStalkerPortal( page: Page, - options: { name?: string; mac: string; expectContent?: boolean } + options: { + name?: string; + mac: string; + expectContent?: boolean; + username?: string; + password?: string; + } ): Promise { - const { name = 'Full Stalker Portal', mac, expectContent = true } = options; + const { + name = 'Full Stalker Portal', + mac, + expectContent = true, + username, + password, + } = options; await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -206,6 +268,12 @@ async function addFullStalkerPortal( await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); await setInputValue(dialog.locator('input#macAddress'), mac); + if (username !== undefined) { + await setInputValue(dialog.locator('input#username'), username); + } + if (password !== undefined) { + await setInputValue(dialog.locator('input#password'), password); + } const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); await expect(addButton).toBeEnabled({ timeout: 10_000 }); @@ -681,6 +749,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, }) => { @@ -917,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => { .toBe(true); }); - test('never surfaces the portal plain-text auth failure as content', async ({ + test('completes the documented login flow behind get_profile status 2', async ({ + page, + }) => { + const requests = recordPortalRequests(page); + // The second auth step must only be claimed on the retry AFTER + // do_auth — a client that always sends auth_second_step=1 works + // against the mock but violates the documented protocol. + const profileSteps: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + if (url.searchParams.get('action') !== 'get_profile') { + return; + } + profileSteps.push(url.searchParams.get('auth_second_step') ?? ''); + }); + + await addFullStalkerPortal(page, { + mac: LOGIN_REQUIRED_MAC, + username: 'user', + password: 'secret', + }); + + // The mock only adopts the token (and answers content) after do_auth + // completed with non-empty credentials, so rendered categories prove + // the whole status 2 -> do_auth -> profile retry chain ran. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + + const actions = requests.map((entry) => entry.action); + expect(actions).toContain('do_auth'); + expect(actions.indexOf('get_profile')).toBeLessThan( + actions.indexOf('do_auth') + ); + expect(actions.lastIndexOf('get_profile')).toBeGreaterThan( + actions.indexOf('do_auth') + ); + + expect(profileSteps[0]).toBe('0'); + expect(profileSteps).toContain('1'); + + // Content only flows once the token is adopted, which the mock does + // only after do_auth completed. Look AFTER the last get_profile: + // discovery's classification probe is itself a token-less + // `get_genres`, so the first CONTENT_ACTIONS hit is that probe. + const contentRequest = requests + .slice(actions.lastIndexOf('get_profile') + 1) + .find((entry) => CONTENT_ACTIONS.includes(entry.action)); + expect(contentRequest).toBeDefined(); + expect(contentRequest?.token).toBeTruthy(); + }); + + test('explains a login-required portal and recovers once credentials are entered', async ({ + page, + }) => { + // First attempt without credentials: the import must fail with the + // portal's actual reason, not a generic "check URL and MAC" error. + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), 'Login Portal'); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue( + dialog.locator('input#macAddress'), + LOGIN_REQUIRED_MAC + ); + + const addButton = dialog.getByRole('button', { + name: 'Add', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + + await expect( + page.getByText(/requires a login and password/i) + ).toBeVisible({ timeout: 15_000 }); + // The dialog stays open so the user can add the credentials. + await expect(dialog).toBeVisible(); + + // Second attempt with credentials in the same dialog succeeds. + await setInputValue(dialog.locator('input#username'), 'user'); + await setInputValue(dialog.locator('input#password'), 'secret'); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + }); + + test('reuses the persisted token after a reload instead of re-running get_profile', async ({ + page, + }, testInfo) => { + const requests = recordPortalRequests(page); + const mac = + AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC; + + await addFullStalkerPortal(page, { mac }); + + await expect + .poll( + () => + requests.some( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token + ), + { timeout: 30_000 } + ) + .toBe(true); + + const sessionToken = requests.find( + (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token + )?.token; + expect(sessionToken).toBeTruthy(); + + const requestCountBeforeReload = requests.length; + await page.reload(); + + // Wait on the UI rather than the request log: a reload restores the + // route, boots the app and re-authenticates before the first content + // request goes out, and rendered categories prove that whole chain + // finished (the portal only answers content for an adopted token). + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 60_000, + }); + + const afterReload = requests.slice(requestCountBeforeReload); + + // Content came back under the SAME token, negotiated by re-presenting + // the persisted one in the handshake. + expect( + afterReload.filter( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token === sessionToken + ).length + ).toBeGreaterThan(0); + + // The handshake re-presented the persisted token... + const reloadHandshake = afterReload.find( + (entry) => entry.action === 'handshake' + ); + expect(reloadHandshake?.token).toBe(sessionToken); + + // ...and because the portal returned it unchanged (idempotent + // handshake, still-adopted session), the profile round trip was + // skipped entirely. + expect( + afterReload.filter((entry) => entry.action === 'get_profile') + ).toHaveLength(0); + }); + + test('refuses a rejected portal at import and never renders its plain-text failure', async ({ page, request, }) => { - const requests = recordPortalRequests(page); - // A MAC outside the Infomir OUI makes the strict endpoint answer // get_profile with a bare {status:1}, so no token is ever adopted and // every content request keeps returning the plain-text failure. Unlike @@ -940,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => { ).json(); expect(failureBody.payload).toBe('Authorization failed.'); - await addFullStalkerPortal(page, { - mac: AUTH_REJECTED_MAC, - expectContent: false, + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), 'Rejected Portal'); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue( + dialog.locator('input#macAddress'), + AUTH_REJECTED_MAC + ); + + const addButton = dialog.getByRole('button', { + name: 'Add', + exact: true, }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); - // The app must have actually hit the failing portal... - await expect - .poll( - () => - requests.filter((entry) => - CONTENT_ACTIONS.includes(entry.action) - ).length, - { timeout: 30_000 } - ) - .toBeGreaterThan(0); + // `status: 1` means the portal refused this device, so the import must + // stop there instead of persisting a portal that can never load. (It + // used to be treated as success whenever the portal sent no msg text, + // which imported a dead source.) + await expect(page.getByText(/refused access/i)).toBeVisible({ + timeout: 15_000, + }); + await expect(dialog).toBeVisible(); + await expect(page).not.toHaveURL(/stalker/); - // ...and must never render the raw portal response as content. A - // portal answers auth failures with HTTP 200 + plain text, so an app - // that trusts the status code would happily paint these strings. + // And the raw portal response is never painted as UI. A portal answers + // auth failures with HTTP 200 + plain text, so an app that trusts the + // status code would happily render these strings. await expect(page.locator('body')).not.toContainText( 'Authorization failed.' ); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d384fc23e..d583836f4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -195,7 +195,13 @@ "SERVER_URL": "رابط الخادم", "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)", "ADD": "إضافة", - "VALIDATING": "جارٍ التحقق من البوابة..." + "VALIDATING": "جارٍ التحقق من البوابة...", + "CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور", + "AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.", + "LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.", + "LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.", + "PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.", + "PORTAL_MESSAGE": "أفادت البوابة: {{message}}" }, "FILTER_BY_NAME": "التصفية حسب الاسم", "FILTER_AND_SORT": "تصفية وفرز", @@ -957,6 +963,7 @@ "TITLE": "لم يتم تحديد فئة", "DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى" }, + "STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.", "PLAYLIST_SETTINGS": "إعدادات القائمة", "HOME": "الرئيسية", "DELETE": "حذف" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f686a3dab..961d20ad3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -195,7 +195,13 @@ "SERVER_URL": "رابط الخادم", "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)", "ADD": "زيد", - "VALIDATING": "جاري التحقق من البوابة..." + "VALIDATING": "جاري التحقق من البوابة...", + "CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور", + "AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.", + "LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.", + "LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.", + "PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.", + "PORTAL_MESSAGE": "البوابة قالت: {{message}}" }, "FILTER_BY_NAME": "فلتر بالاسم", "FILTER_AND_SORT": "فلتر وترتيب", @@ -957,6 +963,7 @@ "TITLE": "ما كاين حتى فئة متختارة", "DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى" }, + "STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.", "PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل", "HOME": "الرئيسية", "DELETE": "حذف" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7e31c2f4f..97630f1e7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL сервера", "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).", "ADD": "Дадаць", - "VALIDATING": "Праверка партала..." + "VALIDATING": "Праверка партала...", + "CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль", + "AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.", + "LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.", + "LOGIN_REJECTED": "Партал адхіліў лагін і пароль.", + "PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.", + "PORTAL_MESSAGE": "Партал паведаміў: {{message}}" }, "FILTER_BY_NAME": "Фільтраваць па імені", "FILTER_AND_SORT": "Фільтр і сартаванне", @@ -957,6 +963,7 @@ "TITLE": "Катэгорыя не выбрана", "DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт" }, + "STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.", "PLAYLIST_SETTINGS": "Налады плэйліста", "HOME": "На галоўную", "DELETE": "Выдаліць" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 39e022ce4..f4989975e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server-URL", "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)", "ADD": "Hinzufügen", - "VALIDATING": "Portal wird überprüft …" + "VALIDATING": "Portal wird überprüft …", + "CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt", + "AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.", + "LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.", + "LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.", + "PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.", + "PORTAL_MESSAGE": "Das Portal meldet: {{message}}" }, "FILTER_BY_NAME": "Nach Namen filtern", "FILTER_AND_SORT": "Filtern & Sortieren", @@ -957,6 +963,7 @@ "TITLE": "Keine Kategorie ausgewählt", "DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen" }, + "STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.", "PLAYLIST_SETTINGS": "Playlist-Einstellungen", "HOME": "Startseite", "DELETE": "Löschen" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a2107398a..0f4ca122b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -195,7 +195,13 @@ "SERVER_URL": "Διεύθηνση URL σέρβερ", "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)", "ADD": "Προσθήκη", - "VALIDATING": "Επικύρωση πύλης..." + "VALIDATING": "Επικύρωση πύλης...", + "CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης", + "AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.", + "LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.", + "LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.", + "PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.", + "PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}" }, "FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα", "FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση", @@ -957,6 +963,7 @@ "TITLE": "Δεν έχει επιλεγεί κατηγορία", "DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο" }, + "STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.", "PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής", "HOME": "Αρχικό", "DELETE": "Διαγραφή" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 39110d498..a86135fd8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server URL", "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)", "ADD": "Add", - "VALIDATING": "Validating portal..." + "VALIDATING": "Validating portal...", + "CREDENTIALS_HINT": "Only needed when the portal asks for a login and password", + "AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.", + "LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.", + "LOGIN_REJECTED": "The portal rejected the login and password.", + "PORTAL_REFUSED": "The portal refused access for this device.", + "PORTAL_MESSAGE": "The portal reported: {{message}}" }, "FILTER_BY_NAME": "Filter by name", "FILTER_AND_SORT": "Filter & Sort", @@ -957,6 +963,7 @@ "TITLE": "No category selected", "DESCRIPTION": "Please select a category to see the content" }, + "STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.", "PLAYLIST_SETTINGS": "Playlist Settings", "HOME": "Home", "DELETE": "Delete" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index dca10e3d1..c936ccc3e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL del servidor", "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).", "ADD": "Agregar", - "VALIDATING": "Validando portal…" + "VALIDATING": "Validando portal…", + "CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña", + "AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.", + "LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.", + "LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.", + "PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.", + "PORTAL_MESSAGE": "El portal informó: {{message}}" }, "FILTER_BY_NAME": "Filtrar por nombre", "FILTER_AND_SORT": "Filtrar y ordenar", @@ -957,6 +963,7 @@ "TITLE": "Ninguna categoría seleccionada", "DESCRIPTION": "Selecciona una categoría para ver el contenido" }, + "STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.", "PLAYLIST_SETTINGS": "Configuración de lista de reproducción", "HOME": "Hogar", "DELETE": "Borrar" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7199e0e6f..9bc0421ef 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL du serveur", "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)", "ADD": "Ajouter", - "VALIDATING": "Validation du portail…" + "VALIDATING": "Validation du portail…", + "CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe", + "AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.", + "LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.", + "LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.", + "PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.", + "PORTAL_MESSAGE": "Le portail a signalé : {{message}}" }, "FILTER_BY_NAME": "Filtrer par nom", "FILTER_AND_SORT": "Filtrer et trier", @@ -957,6 +963,7 @@ "TITLE": "Aucune catégorie sélectionnée", "DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu" }, + "STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.", "PLAYLIST_SETTINGS": "Paramètres de la liste", "HOME": "Accueil", "DELETE": "Supprimer" diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 363518a0f..ee09db2be 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -195,7 +195,13 @@ "SERVER_URL": "Kiszolgáló URL-címe", "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)", "ADD": "Hozzáadás", - "VALIDATING": "Portál ellenőrzése…" + "VALIDATING": "Portál ellenőrzése…", + "CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér", + "AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.", + "LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.", + "LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.", + "PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.", + "PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}" }, "FILTER_BY_NAME": "Szűrés név alapján", "FILTER_AND_SORT": "Szűrés és rendezés", @@ -957,6 +963,7 @@ "TITLE": "Nincs kiválasztott kategória", "DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát." }, + "STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.", "PLAYLIST_SETTINGS": "Lejátszási lista beállításai", "HOME": "Kezdőlap", "DELETE": "Törlés" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 802e180a9..5212c6bfe 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL server", "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)", "ADD": "Aggiungi", - "VALIDATING": "Validazione del portale..." + "VALIDATING": "Validazione del portale...", + "CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password", + "AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.", + "LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.", + "LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.", + "PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.", + "PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}" }, "FILTER_BY_NAME": "Filtra per nome", "FILTER_AND_SORT": "Filtra e ordina", @@ -957,6 +963,7 @@ "TITLE": "Nessuna categoria selezionata", "DESCRIPTION": "Seleziona una categoria per vedere i contenuti" }, + "STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.", "PLAYLIST_SETTINGS": "Impostazioni Playlist", "HOME": "Home", "DELETE": "Elimina" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f3b08d41..49e89011e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -195,7 +195,13 @@ "SERVER_URL": "サーバーURL", "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)", "ADD": "追加", - "VALIDATING": "ポータルを検証中..." + "VALIDATING": "ポータルを検証中...", + "CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要", + "AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。", + "LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。", + "LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。", + "PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。", + "PORTAL_MESSAGE": "ポータルからの報告:{{message}}" }, "FILTER_BY_NAME": "名前でフィルター", "FILTER_AND_SORT": "フィルターと並び替え", @@ -957,6 +963,7 @@ "TITLE": "カテゴリーが選択されていません", "DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください" }, + "STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。", "PLAYLIST_SETTINGS": "プレイリスト設定", "HOME": "ホーム", "DELETE": "削除" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 59b734ff5..999697dee 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -195,7 +195,13 @@ "SERVER_URL": "서버 URL", "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).", "ADD": "추가하다", - "VALIDATING": "포털을 검증하는 중..." + "VALIDATING": "포털을 검증하는 중...", + "CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요", + "AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.", + "LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.", + "LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.", + "PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.", + "PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}" }, "FILTER_BY_NAME": "이름으로 필터", "FILTER_AND_SORT": "필터 및 정렬", @@ -957,6 +963,7 @@ "TITLE": "선택된 카테고리가 없습니다", "DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요" }, + "STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.", "PLAYLIST_SETTINGS": "재생목록 설정", "HOME": "홈", "DELETE": "삭제" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 3927b34cc..0038dcc28 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server URL", "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)", "ADD": "Toevoegen", - "VALIDATING": "Portaal valideren..." + "VALIDATING": "Portaal valideren...", + "CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt", + "AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.", + "LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.", + "LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.", + "PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.", + "PORTAL_MESSAGE": "Het portaal meldde: {{message}}" }, "FILTER_BY_NAME": "Filter op naam", "FILTER_AND_SORT": "Filteren & sorteren", @@ -957,6 +963,7 @@ "TITLE": "Geen categorie geselecteerd", "DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken" }, + "STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.", "PLAYLIST_SETTINGS": "Afspeellijst instellingen", "HOME": "Home", "DELETE": "Verwijderen" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2f4d4e800..40670643a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL serwera", "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)", "ADD": "Dodaj", - "VALIDATING": "Walidacja portalu..." + "VALIDATING": "Walidacja portalu...", + "CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła", + "AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.", + "LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.", + "LOGIN_REJECTED": "Portal odrzucił login i hasło.", + "PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.", + "PORTAL_MESSAGE": "Portal zgłosił: {{message}}" }, "FILTER_BY_NAME": "Filtruj według nazwy", "FILTER_AND_SORT": "Filtruj i sortuj", @@ -957,6 +963,7 @@ "TITLE": "Nie wybrano kategorii", "DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści" }, + "STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.", "PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania", "HOME": "Strona główna", "DELETE": "Usuń" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index cbf5b10dc..376dcd436 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL do servidor", "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)", "ADD": "Adicionar", - "VALIDATING": "Validando portal..." + "VALIDATING": "Validando portal...", + "CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha", + "AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.", + "LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.", + "LOGIN_REJECTED": "O portal rejeitou o login e a senha.", + "PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.", + "PORTAL_MESSAGE": "O portal informou: {{message}}" }, "FILTER_BY_NAME": "Filtrar por nome", "FILTER_AND_SORT": "Filtrar e ordenar", @@ -957,6 +963,7 @@ "TITLE": "Nenhuma categoria selecionada", "DESCRIPTION": "Selecione uma categoria para ver o conteúdo" }, + "STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.", "PLAYLIST_SETTINGS": "Configurações da playlist", "HOME": "Início", "DELETE": "Excluir" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 685bf964c..955ecd3dc 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL сервера", "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).", "ADD": "Добавить", - "VALIDATING": "Проверка портала…" + "VALIDATING": "Проверка портала…", + "CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль", + "AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.", + "LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.", + "LOGIN_REJECTED": "Портал отклонил логин и пароль.", + "PORTAL_REFUSED": "Портал отказал в доступе этому устройству.", + "PORTAL_MESSAGE": "Портал сообщил: {{message}}" }, "FILTER_BY_NAME": "Фильтровать по имени", "FILTER_AND_SORT": "Фильтр и сортировка", @@ -957,6 +963,7 @@ "TITLE": "Категория не выбрана", "DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого" }, + "STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.", "PLAYLIST_SETTINGS": "Настройки плейлиста", "HOME": "На главную", "DELETE": "Удалить" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 99718c5fb..ea99b075a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -195,7 +195,13 @@ "SERVER_URL": "Sunucu URL'si", "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)", "ADD": "Ekle", - "VALIDATING": "Portal doğrulanıyor..." + "VALIDATING": "Portal doğrulanıyor...", + "CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir", + "AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.", + "LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.", + "LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.", + "PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.", + "PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}" }, "FILTER_BY_NAME": "İsime göre filtrele", "FILTER_AND_SORT": "Filtrele ve Sırala", @@ -957,6 +963,7 @@ "TITLE": "Kategori seçilmedi", "DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin" }, + "STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.", "PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları", "HOME": "Ana Sayfa", "DELETE": "Sil" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bf74b355b..d69b1134d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -195,7 +195,13 @@ "SERVER_URL": "服务器 URL", "URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", "ADD": "添加", - "VALIDATING": "正在验证门户…" + "VALIDATING": "正在验证门户…", + "CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写", + "AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。", + "LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。", + "LOGIN_REJECTED": "门户拒绝了该登录名和密码。", + "PORTAL_REFUSED": "门户拒绝了此设备的访问。", + "PORTAL_MESSAGE": "门户返回信息:{{message}}" }, "FILTER_BY_NAME": "按名称筛选", "FILTER_AND_SORT": "筛选与排序", @@ -957,6 +963,7 @@ "TITLE": "未选择分类", "DESCRIPTION": "请选择一个分类以查看内容" }, + "STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。", "PLAYLIST_SETTINGS": "播放列表设置", "HOME": "首页", "DELETE": "删除" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 866127200..ecd350563 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -195,7 +195,13 @@ "SERVER_URL": "伺服器網址", "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", "ADD": "新增", - "VALIDATING": "正在驗證入口網站..." + "VALIDATING": "正在驗證入口網站...", + "CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫", + "AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。", + "LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。", + "LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。", + "PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。", + "PORTAL_MESSAGE": "入口網站回報:{{message}}" }, "FILTER_BY_NAME": "依名稱篩選", "FILTER_AND_SORT": "篩選與排序", @@ -957,6 +963,7 @@ "TITLE": "未選擇類別", "DESCRIPTION": "請選擇一個類別以查看內容" }, + "STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。", "PLAYLIST_SETTINGS": "播放清單設定", "HOME": "首頁", "DELETE": "刪除" 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..bc9b50bd0 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -83,9 +83,9 @@ Two portal modes exist, persisted per playlist as the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`), since a panel fills those in deliberately. While a full portal is the active playlist, `StalkerSessionService` keeps a **watchdog** running — - periodic authenticated `watchdog/get_events` pings (currently every 25 s; - the protocol default expects 120 s, tracked for a later PR) whose failures - are non-fatal. + periodic authenticated `watchdog/get_events` pings at the cadence the + portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog" + below) whose failures are non-fatal. - **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle at all — requests carry only the `mac=` cookie. @@ -235,6 +235,181 @@ blank fields are not generated or forwarded to `get_profile`. metadata is independent from M3U playlist EPG metadata and must not depend on M3U-specific EPG fields. +## Session Authentication Lifecycle + +Full portals authenticate through `StalkerSessionService` +(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which +is a facade over three focused modules: + +- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth` + requests and the `authenticate()` orchestration. +- `stalker-watchdog.controller.ts` — the periodic `get_events` keep-alive. +- `stalker-token-cache.ts` — the in-run token and pending-auth state, tagged + with the identity fingerprint each session was negotiated for. +- `stalker-session-store.ts` — the session persisted on the playlist row. +- `stalker-response-classification.ts` + `stalker-portal-error.ts` — failure + detection and the typed `StalkerPortalError` the UI layers render. + +### Handshake and token persistence + +The handshake token is **idempotent** (Stalker 4.9.35 `stb.class.php`): +re-presenting the MAC's current session token returns it unchanged, and tokens +have no TTL — they are only invalidated when another device runs `get_profile` +on the same MAC. `ensureToken()` exploits this: when the in-memory cache is +cold it re-presents the persisted `Playlist.stalkerToken` (from the playlist +object, falling back to the stored row via `PlaylistsService`, since store +metas do not carry payload fields). A token that comes back unchanged is an +already-adopted session, so the `get_profile` round trip is skipped entirely — +unless the handshake also set `not_valid`, which vetoes the shortcut: adopting +a token the portal just called dead would be unrecoverable, since the reuse +path writes no replacement back and every later start would re-present it. +A renegotiated session is written back best-effort +(`PlaylistsService.updateStalkerSession`) so the next app start can reuse it. +The handshake's `not_valid` flag is propagated into the follow-up +`get_profile` as `not_valid_token`. + +Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering +the **portal endpoint (origin AND path), the device identity and the account +credentials**, stored +next to the token as `Playlist.stalkerSessionIdentity` and used for the +in-run cache as well, so an edit applies without a restart. All three halves +are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an +endpoint edit would otherwise disclose the previous portal's bearer token to +another portal — and origin alone is not enough, since discovery deliberately +preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host +are different portals; an identity edit must not inherit the old session; and for a +status-2 portal the login decides which account the token represents. A token +with no recorded fingerprint (written before this existed) counts as +unverified and is never re-presented — such a row owes a full profile anyway, +and the write-back then records the fingerprint. + +Because that reuse skips the only response carrying the watchdog cadence, the +cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` / +`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema +change) and re-applied on the reuse path. The import dialog persists what its +own `get_profile` advertised, which for a portal whose token never goes stale +is the only profile the app ever sees. + +The skip is therefore conditional on the cadence being **known**: a playlist +imported before the cadence was persisted has a reusable token and no +cadence, and skipping would strand it on the 120 s default permanently, since +the profile is the only thing that could teach it. Such a playlist runs one +profile, persists what it learns, and skips from then on. What gets persisted +is the *effective* cadence (the 120 s default when the portal advertises +none), so stored absence keeps meaning exactly one thing — never profiled — +rather than sending a portal that advertises nothing back through a profile on +every start. + +### `get_profile` status decoding + +`authenticate()` decodes `js.status` the way the stock middleware means it: + +- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read + for the watchdog cadence. +- `status: 1` — blocked (device conflict, malformed MAC, disabled account). + `msg`/`block_msg` carry the portal's own explanation; they are + markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`. +- `status: 2` — login/password required. The client runs `do_auth` + (`login`, `password`, plus `device_id`/`device_id2` when configured) and + retries `get_profile` with `auth_second_step=1`. Only that retry claims the + second auth step — the initial request sends `auth_second_step=0`. Missing + credentials throw `StalkerPortalError('login-required')`; a `{js: false}` + verdict (the operator billing script refused) throws `'login-rejected'`. + +Credentials come from the import dialog's username/password fields and are +persisted on the playlist, so runtime re-authentication can repeat `do_auth` +after the portal drops the session. + +### Plain-text failure bodies + +Auth failures are **HTTP 200 + a text/html body**, never a 401/403. The three +exact bodies (`Authorization failed.` — stale/missing token, optionally with a +numeric debug suffix; `Access denied.` — blocked account; +`Unauthorized request.` — missing mac cookie) are classified at the transport +boundary: the Electron main process +(`apps/electron-backend/src/app/events/stalker.events.ts`) converts them into +a structured `{ stalkerAuthFailure }` marker +(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`) — returned, +not thrown, because `ipcRenderer.invoke` strips custom properties from +rejections. The PWA proxy path still delivers the raw string; the renderer +classifier accepts both shapes plus the legacy `{ js: '' }` envelope. +`makeAuthenticatedRequest` retries once with fresh authentication and +otherwise throws `StalkerPortalError('auth-failed')` carrying the body. + +### Error surfacing + +`StalkerPortalError.portalText` holds the portal's own words. The import +dialog shows them in its failure snackbar (with kind-specific i18n headlines, +`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic +"could not load categories" hint with the portal text (or the login-required +guidance) when category loading failed with a portal refusal +(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`). + +Both renderers are kind-agnostic — they append `portalText` whenever it is +present — so the obligation sits entirely on the throw sites: **every** exit +out of the status-2 branch carries the text, not just the terminal `blocked` +one. A login refusal is precisely where the portal says something actionable +("wrong password", "subscription expired"), and dropping it leaves the user +with a generic line while the useful sentence sits unread in the payload. +Each exit reads the response IN HAND: after the `auth_second_step=1` retry the +text is the retry's, since quoting the first profile back would describe a +request that already succeeded. `do_auth` itself answers a bare `{js: false}`, +so a rejection there keeps the profile's text — the one that asked for the +login. + +### Abandoning an authentication + +`authenticate()` takes an optional `AbortSignal` and checks it before every +portal call. Endpoint discovery gives each confirmation attempt its own +controller and aborts it when the attempt exceeds its budget, before moving to +the next candidate. + +This matters because `get_profile` — not the handshake — is what adopts a +token for the MAC portal-side. Without the check, a timed-out attempt could +still send its `get_profile` after a later candidate had authenticated, +invalidating that healthy candidate's token and making discovery report a +working portal as refused. + +Cancellation is deliberately cooperative rather than a socket-level abort +threaded to axios: once a request is on the wire the server processes it +regardless of what the client does, so tearing the socket down would not +prevent the adoption. Only not sending the request does, which is exactly what +the between-calls check guarantees. + +That leaves the window where the timer fires while a `get_profile` is already +dispatched — which the check cannot cover, but sequencing can: discovery +**drains** the abandoned attempt (bounded by one request budget) before probing +the next candidate, instead of racing it. The request cannot be un-sent, but +nothing forces us to have a competing session in flight while it lands. + +**A drain that times out stops discovery.** Draining is bounded, and the bound +has to mean something: an attempt still unsettled after its own 65 s budget +plus the 15 s drain is one no transport can recall — the PWA `fetch()` takes no +signal at all, and the Electron main process runs its HTTP request to +completion. Advancing anyway would stake the next candidate's freshly issued +session on that request never landing. So the rejection carries +`abandonedInFlight` and the candidate loop returns instead of probing on, +preferring an honest "could not confirm this portal" the user can retry over a +session that looks established and dies later. It costs nothing in the normal +case: an aborted attempt settles as soon as its in-flight request errors out, +so the drain returns at once and the loop continues. + +The budget itself covers the longest real flow: a status-2 portal costs four +sequential requests (handshake, profile, `do_auth`, profile retry) and the +Electron transport allows each 15 s, so a two-request budget would have failed +valid but slow login portals. + +### Watchdog + +The portal expects `get_events` every `watchdog_timeout` seconds — **120 by +default**, echoed in the profile together with a per-user `timeslot` jitter +that offsets the first periodic ping. `StalkerWatchdogController` starts with +an immediate `init=1` ping on activation, applies the profile cadence when a +profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses +the documented 120 s default. Failing to ping never invalidates the session — +it only affects the portal's admin-panel "online" reporting — so ping failures +are logged and never retried or escalated. + ## Request Transport and `cmd` Encoding A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw @@ -305,6 +480,232 @@ 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()` 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 store's player feature reads `getCachedToken()` for its header set, which +is safe there because it runs immediately after the warm above populated the +cache for that same playlist. `StreamResolverService` cannot make that +assumption — a direct-URL favorite reaches it with nothing warmed — so it goes +through `ensureToken` instead; see "Playback Header Contract" below. + +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 +727,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 @@ -340,6 +744,30 @@ Two stream profiles exist, selected by one shared predicate: never reach a third-party host; direct stream URLs carry their access token in the URL minted by `create_link`. +**The token is bound to the endpoint the headers claim.** Both header inputs — +the portal coordinates and the Bearer token — must describe the same portal, or +a session negotiated for one host is presented to another. In +`StreamResolverService` that is structural: the token is resolved from the same +`headerPlaylist` object the headers are built from, never from the row it was +derived from. The live case is a completed lazy repair, which moves the +endpoint in the override but not in the stored row. Resolving from the override +also keys the session cache the way `ensureStalkerSession()` and +`executeStalkerRequest()` already do, so the collection route reuses their +session instead of handshaking again for a second fingerprint. + +For the same reason `headerPlaylist` is built by applying the override to the +row rather than folding in the two resolved coordinates: a repair rewrites the +portal **mode** as well as the URL, and the token resolver is mode-aware. A +playlist repaired from simple to full would otherwise keep its stale +`isFullStalkerPortal: false` here — the `create_link` request having already +run under the repaired mode and adopted a token — and the same-host gated +stream would go out with no Bearer header on the very playback the repair +existed to rescue. + +That resolution is skipped for a foreign host, whose profile carries no token +anyway — obtaining one would only stall playback behind a handshake, exactly +the trade the static branch avoids by classifying first. + The Electron main process keeps a fallback header context per resolved `create_link` URL (`stalker-playback-context.service.ts`) for external-player launches that arrive without renderer headers. It classifies streams with the diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html index e7eb3e63f..57beb00fe 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html @@ -101,16 +101,27 @@ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} - + + {{ + 'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate + }} + diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index d966e25fd..906afa722 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -2,13 +2,19 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access'; +import { + stalkerSessionFingerprint, + StalkerPortalDiscoveryService, + StalkerPortalError, +} from '@iptvnator/portal/stalker/data-access'; +import { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerPortalImportComponent } from './stalker-portal-import.component'; describe('StalkerPortalImportComponent identity handling', () => { let component: StalkerPortalImportComponent; let portalDiscovery: { discover: jest.Mock }; let store: { dispatch: jest.Mock }; + let snackBar: { open: jest.Mock }; beforeEach(() => { portalDiscovery = { @@ -23,6 +29,7 @@ describe('StalkerPortalImportComponent identity handling', () => { store = { dispatch: jest.fn(), }; + snackBar = { open: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -33,7 +40,7 @@ describe('StalkerPortalImportComponent identity handling', () => { { provide: Store, useValue: store }, { provide: MatSnackBar, - useValue: { open: jest.fn() }, + useValue: snackBar, }, { provide: TranslateService, @@ -72,7 +79,10 @@ describe('StalkerPortalImportComponent identity handling', () => { deviceId2: 'DEVICE-ID-2', signature1: 'SIGNATURE-1', signature2: 'SIGNATURE-2', - } + }, + // Credentials ride along for portals that answer get_profile + // with status 2 (login/password required). + { credentials: { username: '', password: '' } } ); const playlist = store.dispatch.mock.calls[0][0].playlist; @@ -115,7 +125,8 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(portalDiscovery.discover).toHaveBeenCalledWith( 'https://portal.example.com/stalker_portal/c', '00:1A:79:AA:BB:CC', - {} + {}, + { credentials: { username: '', password: '' } } ); const playlist = store.dispatch.mock.calls[0][0].playlist; @@ -131,6 +142,170 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(playlist.signature2).toBeUndefined(); }); + it('passes the entered login and password into discovery', async () => { + component.form.patchValue({ + _id: 'playlist-login', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + username: 'user', + password: 'secret', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(portalDiscovery.discover).toHaveBeenCalledWith( + expect.any(String), + '00:1A:79:00:00:08', + {}, + { credentials: { username: 'user', password: 'secret' } } + ); + // Persisted so runtime re-auth can repeat do_auth after the portal + // drops the session. + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.username).toBe('user'); + expect(playlist.password).toBe('secret'); + }); + + it('persists the cadence and the identity the token was negotiated for', async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + watchdogTimeoutSeconds: 90, + timeslotSeconds: 11, + }); + component.form.patchValue({ + _id: 'playlist-cadence', + title: 'Cadence Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerWatchdogTimeout).toBe(90); + expect(playlist.stalkerTimeslot).toBe(11); + // Without this a later start would re-present the token under an + // edited identity. + expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String)); + }); + + it('records credentials in the imported session fingerprint', async () => { + // Otherwise the first runtime ensureToken() computes a fingerprint + // WITH the credentials, mismatches the imported one, and discards the + // session the import just established — silently defeating reuse for + // exactly the login portals this flow exists for. + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }); + component.form.patchValue({ + _id: 'playlist-login-fp', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + username: 'user', + password: 'secret', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerSessionIdentity).toBe( + stalkerSessionFingerprint({ + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:08', + username: 'user', + password: 'secret', + } as Playlist) + ); + }); + + it('records the effective cadence when the portal advertises none', async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }); + component.form.patchValue({ + _id: 'playlist-default-cadence', + title: 'Quiet Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + // Stored absence has to keep meaning "never profiled", or every + // later start would re-profile such a portal. + expect(playlist.stalkerWatchdogTimeout).toBe(120); + expect(playlist.stalkerTimeslot).toBe(0); + }); + + it("relays the portal's own refusal instead of a generic error", async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + error: new StalkerPortalError('login-required'), + }); + component.form.patchValue({ + _id: 'playlist-refused', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.LOGIN_REQUIRED', + undefined, + expect.any(Object) + ); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("appends the portal's explanation to a blocked refusal", async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + error: new StalkerPortalError( + 'blocked', + 'device conflict - device_id mismatch' + ), + }); + component.form.patchValue({ + _id: 'playlist-blocked', + title: 'Blocked Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + // The translate mock returns keys, so both the kind headline and the + // portal-message wrapper must be present. + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE', + undefined, + expect.any(Object) + ); + }); + it('classifies the offline fallback on the normalized URL, not the raw query', async () => { // A query merely MENTIONING /server/load.php must not make a // panel-style /c URL look canonical and abort the offline import. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 5a0b67222..86888fb92 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -13,11 +13,15 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { + asStalkerPortalError, legacyTransformStalkerPortalUrl, normalizeStalkerPortalInputUrl, + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, StalkerPortalDiscoveryService, StalkerPortalIdentity, normalizeStalkerPortalIdentity, + stalkerSessionFingerprint, + type StalkerPortalErrorKind, } from '@iptvnator/portal/stalker/data-access'; import { createRandomId, @@ -129,18 +133,37 @@ export class StalkerPortalImportComponent { const discovery = await this.portalDiscovery.discover( originalUrl, formValue.macAddress ?? '', - stalkerIdentity + stalkerIdentity, + { + credentials: { + username: formValue.username ?? '', + password: formValue.password ?? '', + }, + } ); let portalUrl: string; let isFullStalkerPortal: boolean; let stalkerToken: string | undefined; let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; + // The import profile is the only get_profile some portals ever + // see: later starts reuse the token and skip it, so the cadence + // it advertises has to be persisted here or the watchdog would + // stay on the 120 s default forever. Effective values, so stored + // absence keeps meaning "never profiled". + let stalkerWatchdogTimeout: number | undefined; + let stalkerTimeslot: number | undefined; if (discovery.status === 'resolved') { portalUrl = discovery.portalUrl; isFullStalkerPortal = discovery.isFullStalkerPortal; stalkerToken = discovery.token; + if (stalkerToken) { + stalkerWatchdogTimeout = + discovery.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS; + stalkerTimeslot = discovery.timeslotSeconds ?? 0; + } if (discovery.accountInfo) { stalkerAccountInfo = { @@ -167,10 +190,13 @@ export class StalkerPortalImportComponent { '[StalkerImport] Authentication failed:', discovery.error ); + // The portal explains its own refusals — a demanded login, a + // rejected one, a device conflict — so relay those words + // instead of the generic "check URL and MAC". this.snackBar.open( - 'Failed to authenticate with portal. Please check URL and MAC address.', + this.buildAuthErrorMessage(discovery.error), undefined, - { duration: 5000 } + { duration: 8000 } ); return; } else if ( @@ -219,6 +245,25 @@ export class StalkerPortalImportComponent { portalUrl, isFullStalkerPortal, stalkerToken, + // What this token was negotiated for: endpoint, identity AND + // credentials. Reuse is refused when any of them no longer + // matches — and the credentials must be included here, or the + // first runtime `ensureToken()` would compute a fingerprint + // WITH them, mismatch this one, and throw away the session + // the import just established. + ...(stalkerToken + ? { + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress: formValue.macAddress ?? '', + username: formValue.username ?? '', + password: formValue.password ?? '', + ...this.toPlaylistIdentityFields(stalkerIdentity), + } as Playlist), + } + : {}), + stalkerWatchdogTimeout, + stalkerTimeslot, stalkerAccountInfo, ...this.toPlaylistIdentityFields(stalkerIdentity), } as Playlist; @@ -230,6 +275,36 @@ export class StalkerPortalImportComponent { } } + /** + * Turns an authentication failure into a message the user can act on. + * The portal explains refusals itself (`msg`/`block_msg`, or one of the + * documented plain-text bodies); its own words are appended verbatim. + */ + private buildAuthErrorMessage(error: unknown): string { + const portalError = asStalkerPortalError(error); + const keyByKind: Record = { + 'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED', + 'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED', + blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED', + 'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED', + }; + const base = this.translate.instant( + portalError + ? keyByKind[portalError.kind] + : 'HOME.STALKER_PORTAL.AUTH_FAILED' + ); + + if (portalError?.portalText) { + const detail = this.translate.instant( + 'HOME.STALKER_PORTAL.PORTAL_MESSAGE', + { message: portalError.portalText } + ); + return `${base} ${detail}`; + } + + return base; + } + private toPlaylistIdentityFields(identity: StalkerPortalIdentity): { stalkerSerialNumber?: string; stalkerDeviceId1?: string; 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..a5451750f 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 @@ -4,7 +4,10 @@ import { XtreamApiService, XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; -import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerPortalRepairService, + StalkerSessionService, +} from '@iptvnator/portal/stalker/data-access'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { DataService, @@ -25,6 +28,7 @@ describe('StreamResolverService', () => { let dataService: { sendIpcEvent: jest.Mock }; let stalkerSession: { getCachedToken: jest.Mock; + ensureToken: jest.Mock; makeAuthenticatedRequest: jest.Mock; }; let epgBridge: Partial; @@ -44,6 +48,7 @@ describe('StreamResolverService', () => { }; stalkerSession = { getCachedToken: jest.fn(() => null), + ensureToken: jest.fn().mockResolvedValue({ token: null }), makeAuthenticatedRequest: jest.fn(), }; epgBridge = { @@ -703,6 +708,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({ @@ -749,7 +796,10 @@ describe('StreamResolverService', () => { isFullStalkerPortal: true, } satisfies Partial) ); - stalkerSession.getCachedToken.mockReturnValue('TOKEN77'); + // Playback goes through ensureToken, never the raw cache accessor: + // that one skips the endpoint/identity/credential check, so an + // edited playlist would put the old account's token in the headers. + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' }); stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' }, }); @@ -765,7 +815,7 @@ describe('StreamResolverService', () => { stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg', } satisfies UnifiedCollectionItem); - expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1'); + expect(stalkerSession.ensureToken).toHaveBeenCalled(); expect(playback.headers?.['Cookie']).toContain( 'mac=00:11:22:33:44:55' ); @@ -775,6 +825,202 @@ describe('StreamResolverService', () => { expect(playback.origin).toBe('https://stalker.example.com'); }); + it('binds the playback token to the endpoint the headers claim', async () => { + // A completed repair moved the endpoint. The headers are built from + // that moved endpoint, so the session must be negotiated for it too — + // authenticating against the pre-repair row would send a token minted + // for one host to another (and key the session cache differently from + // every other consumer, forcing a redundant handshake). + const stored = { + _id: 'stalker-1', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial; + const repaired = + 'https://new.example.com/stalker_portal/server/load.php'; + playlistsService.getPlaylistById.mockReturnValue(of(stored)); + jest.spyOn( + TestBed.inject(StalkerPortalRepairService), + 'applyOverride' + ).mockImplementation( + (playlist: any) => ({ ...playlist, portalUrl: repaired }) as any + ); + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' }); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::88', + name: 'Moved Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '88', + stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ portalUrl: repaired }) + ); + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88'); + expect(playback.origin).toBe('https://new.example.com'); + }); + + it('carries a repaired simple→full mode into the playback headers', async () => { + // A lazy repair rewrites the portal MODE as well as the URL. The + // create_link request runs under the repaired mode and adopts a token, + // so reading the stored row's stale `isFullStalkerPortal: false` when + // resolving that token would drop the Bearer header from the very + // playback the repair was meant to rescue. + const stored = { + _id: 'stalker-1', + portalUrl: 'https://portal.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial; + playlistsService.getPlaylistById.mockReturnValue(of(stored)); + jest.spyOn( + TestBed.inject(StalkerPortalRepairService), + 'applyOverride' + ).mockImplementation( + (playlist: any) => + ({ + ...playlist, + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + }) as any + ); + stalkerSession.ensureToken.mockResolvedValue({ token: 'REPAIRED77' }); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://portal.example.com:8080/live/95.ts' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::95', + name: 'Repaired Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '95', + stalkerCmd: 'ffrt3 http://portal.example.com/media/95.mpg', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }) + ); + expect(playback.headers?.['Authorization']).toBe('Bearer REPAIRED77'); + }); + + it('authenticates a cold session for a direct-URL radio favorite', async () => { + // A direct-URL radio favorite skips create_link entirely, so on a + // cold session nothing has authenticated and the in-memory cache is + // empty — the Bearer-gated stream would 403 in the audio player. + // Re-presenting the persisted token costs one idempotent handshake. + 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, + stalkerToken: 'PERSISTED77', + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockResolvedValue({ + token: 'PERSISTED77', + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::99', + name: 'Gated Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://stalker.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + // The fast path must stay a fast path: no create_link round trip. + expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled(); + expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77'); + }); + + it('falls back to create_link when the cold session cannot be established', async () => { + // A portal-owned static stream with no usable session would be served + // knowing it will 401, so playback is not blocked — it takes the + // `create_link` route instead, which mints a URL carrying its own + // token and is the only path that can trigger the lazy portal repair. + 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.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockRejectedValue( + new Error('handshake refused') + ); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::99', + name: 'Gated Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://stalker.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/radio/99-tmp.mp3' + ); + // No session, so no Bearer — the minted URL carries its own token. + expect(playback.headers?.['Authorization']).toBeUndefined(); + }); + + it('does not authenticate a simple portal for playback headers', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-2', + portalUrl: 'https://simple.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue(null); + + await service.resolvePlayback({ + uid: 'stalker::stalker-2::99', + name: 'Simple Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-2', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://simple.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + }); + it('keeps Stalker collection playback from a foreign CDN credential-free', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -808,6 +1054,257 @@ 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: true, + } satisfies Partial) + ); + // A full portal, so the Bearer assertion below is meaningful: only a + // portal with a session has a token to attach at all. + stalkerSession.ensureToken.mockResolvedValue({ token: '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..eedfd3730 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 @@ -8,7 +8,9 @@ import { Channel, EpgItem, EpgProgram, + isFullStalkerPortalPlaylist, Playlist, + isStalkerStreamCredentialSafe, ResolvedPortalPlayback, STALKER_REQUEST, StalkerPortalActions, @@ -19,15 +21,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 +82,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 +339,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 +479,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 @@ -412,7 +506,7 @@ export class StreamResolverService { * them to the stream origin; foreign hosts get the credential-free * profile from the shared classifier). */ - private buildStalkerPlayback( + private async buildStalkerPlayback( item: UnifiedCollectionItem, playlist: Playlist | undefined, resolved: { @@ -421,24 +515,51 @@ export class StreamResolverService { streamUrl: string; isLive?: boolean; } - ): ResolvedPortalPlayback { + ): Promise { // The item may carry portal/mac overrides for playlists that no // longer exist; the builder only reads header-relevant fields. + // + // The override is applied to the ROW, not just folded in as the two + // resolved coordinates: a repair rewrites the portal MODE as well as + // the URL, and a playlist repaired from simple to full would otherwise + // keep its stale `isFullStalkerPortal: false` here. The request that + // just ran adopted a token under the repaired mode, but the mode-aware + // token resolver below would read the stale flag and hand back none — + // emitting a same-host gated stream without the Bearer header on the + // very playback the repair existed to rescue. const headerPlaylist = { - ...(playlist ?? {}), + ...(playlist ? this.portalRepair.applyOverride(playlist) : {}), macAddress: resolved.macAddress, portalUrl: resolved.portalUrl, } as Playlist; - const token = this.stalkerSession.getCachedToken(item.playlistId); + const crossOriginStream = isCrossOriginStalkerStream( + headerPlaylist, + resolved.streamUrl + ); + // Classified before authenticating, for the same reason the static + // branch classifies first: the header builder gives a foreign host the + // credential-free profile, so a token obtained here would be discarded + // — after stalling playback behind a handshake against a portal that + // may be slow or offline while the CDN is perfectly reachable. + // + // When it IS needed, the token is resolved from `headerPlaylist` + // rather than the row it came from: those are the exact coordinates + // the headers claim, so the bearer token and the MAC cookie cannot end + // up bound to a different endpoint than the one they are sent to. A + // repair override that moved the endpoint is the live case — it + // reaches `resolved.portalUrl` but not the raw row, and every other + // session consumer (`ensureStalkerSession`, `executeStalkerRequest`) + // already authenticates against the override, so this also stops the + // resolver from keying the session cache differently and re-shaking. + const token = + playlist && !crossOriginStream + ? await this.resolveStalkerPlaybackToken(headerPlaylist) + : null; const headers = buildStalkerExternalPlaybackHeaders( headerPlaylist, token, resolved.streamUrl ); - const crossOriginStream = isCrossOriginStalkerStream( - headerPlaylist, - resolved.streamUrl - ); const portalOrigin = getStalkerPortalOrigin(headerPlaylist); return { @@ -457,6 +578,40 @@ export class StreamResolverService { }; } + /** + * Resolves the Bearer token a full portal's stream needs. + * + * A direct-URL radio favorite skips `create_link` entirely, so on a cold + * session nothing has authenticated yet and the in-memory cache is empty — + * a same-host Bearer-gated stream would then 403 in the built-in audio + * player. `ensureToken()` re-presents the persisted token instead, which + * is a single idempotent handshake rather than a full re-auth. Failures + * stay non-fatal: many portals do not gate the stream itself. + */ + private async resolveStalkerPlaybackToken( + playlist: Playlist | undefined + ): Promise { + // The shared mode contract, not the raw flag: a legacy row with an + // absent flag but a canonical URL IS a full portal, and reading the + // property directly would skip authentication for it — a restored + // older backup opens a direct-URL radio favorite with no Bearer. + if (!playlist || !isFullStalkerPortalPlaylist(playlist)) { + return null; + } + + // Always through ensureToken, never the raw cache accessor: that one + // skips the endpoint/identity/credential check, so after an edit this + // playback path would put the previous account's token into the + // stream headers. ensureToken returns the cached token when it is + // still valid for this playlist, so a warm session costs nothing. + try { + const { token } = await this.stalkerSession.ensureToken(playlist); + return token; + } catch { + return null; + } + } + private buildStalkerRadioChannel( item: UnifiedCollectionItem, playback: ResolvedPortalPlayback @@ -1168,10 +1323,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/index.ts b/libs/portal/stalker/data-access/src/index.ts index 1b5f1df69..f76f8d307 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -2,6 +2,10 @@ export * from './lib/models'; export * from './lib/stores'; export * from './lib/stalker-account-info.service'; export * from './lib/stalker-content-types'; +export * from './lib/stalker-portal-error'; +export * from './lib/stalker-response-classification'; +export * from './lib/stalker-session-store'; +export * from './lib/stalker-watchdog.controller'; export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; export * from './lib/stalker-portal-discovery.service'; 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-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts new file mode 100644 index 000000000..43b31b6a2 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -0,0 +1,423 @@ +import type { DataService } from '@iptvnator/services'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerAuthApi } from './stalker-auth.api'; + +/** + * Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same + * documented flows — status 2 → do_auth → profile retry, blocked profiles, + * idempotent token reuse, not_valid propagation — asserted against the + * requests the client actually sends. + */ +describe('StalkerAuthApi', () => { + const portalUrl = + 'https://portal.example.com/stalker_portal/server/load.php'; + const macAddress = '00:1A:79:AA:BB:CC'; + + let sendIpcEvent: jest.Mock; + let api: StalkerAuthApi; + + const logger = { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + digest: jest.fn( + async () => new Uint8Array(20).fill(1).buffer + ), + }, + }, + }); + + sendIpcEvent = jest.fn(); + api = new StalkerAuthApi( + { sendIpcEvent } as unknown as DataService, + logger + ); + }); + + function callsByAction(action: string) { + return sendIpcEvent.mock.calls.filter( + (call) => call[1].params.action === action + ); + } + + it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 }, + }) + .mockResolvedValueOnce({ + js: { status: 2, template: 'auth', info: 'Login required' }, + }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ + js: { status: 0, watchdog_timeout: 120, timeslot: 15 }, + }); + + const result = await api.authenticate( + portalUrl, + macAddress, + { deviceId1: 'DEV-1', deviceId2: 'DEV-2' }, + { credentials: { username: 'user', password: 'secret' } } + ); + + expect(result.token).toBe('TOKEN-1'); + expect(result.watchdogTimeoutSeconds).toBe(120); + expect(result.timeslotSeconds).toBe(15); + + // First profile request is NOT the second auth step. + const profiles = callsByAction('get_profile'); + expect(profiles).toHaveLength(2); + expect(profiles[0][1].params.auth_second_step).toBe('0'); + // The retry after do_auth is. + expect(profiles[1][1].params.auth_second_step).toBe('1'); + + // do_auth carries the credentials and the device identity. + const doAuth = callsByAction('do_auth'); + expect(doAuth).toHaveLength(1); + expect(doAuth[0][1].params).toEqual( + expect.objectContaining({ + login: 'user', + password: 'secret', + device_id: 'DEV-1', + device_id2: 'DEV-2', + }) + ); + }); + + it('throws login-required when the portal wants credentials and none are stored', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'login-required', + }); + // Empty credentials are never sent to the billing script. + expect(callsByAction('do_auth')).toHaveLength(0); + }); + + it('throws login-rejected when do_auth answers {js:false}', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }) + .mockResolvedValueOnce({ js: false }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'wrong' }, + }) + ).rejects.toMatchObject({ kind: 'login-rejected' }); + }); + + it('throws login-rejected when the profile still demands a login after do_auth', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ js: { status: 2 } }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + }) + ).rejects.toMatchObject({ kind: 'login-rejected' }); + }); + + it('carries the portal explanation out of every login refusal', async () => { + // The actionable sentence ("wrong password", "subscription expired") + // rides along with the refusal, and the dialog renders it after the + // generic line. Each exit must read the response IN HAND: the retry + // explains its own rejection, and quoting the first profile back + // would describe a request that already succeeded. + const handshake = { js: { token: 'TOKEN-1', random: 'r1' } }; + const credentials = { username: 'user', password: 'secret' }; + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ + js: { status: 2, msg: 'Enter your subscriber login' }, + }); + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'login-required', + portalText: 'Enter your subscriber login', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: false }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + portalText: 'Login needed', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ + js: { status: 2, block_msg: 'Subscription expired' }, + }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + // The retry's text, not the first profile's. + portalText: 'Subscription expired', + }); + }); + + it('decodes a blocked profile into the portal explanation', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ + js: { + status: 1, + msg: 'device conflict - device_id mismatch', + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'blocked', + portalText: + 'device conflict - device_id mismatch — Your STB is damaged. Call the provider.', + }); + }); + + it('treats a bare {status:1} profile as refused', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 1 } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ kind: 'blocked' }); + }); + + it('propagates the handshake not_valid flag into not_valid_token', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STALE-TOKEN', + }); + + const profile = callsByAction('get_profile')[0][1]; + expect(profile.params.not_valid_token).toBe('1'); + }); + + it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => { + sendIpcEvent.mockResolvedValueOnce({ + js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 }, + }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result).toEqual({ + token: 'STORED-TOKEN', + reusedStoredToken: true, + }); + expect(callsByAction('handshake')[0][1].params.token).toBe( + 'STORED-TOKEN' + ); + expect(callsByAction('get_profile')).toHaveLength(0); + }); + + it('runs the full profile when the portal replaces a stale stored token', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STALE-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result.token).toBe('FRESH-TOKEN'); + expect(result.reusedStoredToken).toBe(false); + expect(callsByAction('get_profile')).toHaveLength(1); + }); + + it('does not reuse an echoed token the portal flagged not_valid', async () => { + // `not_valid: 1` is the portal saying the presented token is not a + // live session. Adopting it because the string matched would be + // unrecoverable: nothing writes a replacement back, so every later + // start would re-present the same dead token. + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result.reusedStoredToken).toBe(false); + expect(callsByAction('get_profile')).toHaveLength(1); + expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe( + '1' + ); + }); + + it('decodes a stringified status 2 into the login flow', async () => { + // Portals routinely stringify numeric fields — the same reason + // watchdog_timeout/timeslot accept strings. A strict === would read + // "2" as a healthy profile and skip do_auth entirely. + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: '2' } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ js: { status: '0' } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + }); + + expect(result.token).toBe('TOKEN-1'); + expect(callsByAction('do_auth')).toHaveLength(1); + expect( + callsByAction('get_profile')[1][1].params.auth_second_step + ).toBe('1'); + }); + + it('decodes a stringified status 1 as a refusal', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: '1' } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ kind: 'blocked' }); + }); + + it('never sends get_profile once the caller abandoned the attempt', async () => { + // The harm this prevents: `get_profile` is what adopts a token for + // the MAC portal-side, so an abandoned discovery attempt reaching it + // after another candidate authenticated would invalidate that + // healthy candidate's token. + const abandon = new AbortController(); + sendIpcEvent.mockImplementationOnce(async () => { + abandon.abort(); + return { js: { token: 'TOKEN-1', random: 'r1' } }; + }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(callsByAction('handshake')).toHaveLength(1); + expect(callsByAction('get_profile')).toHaveLength(0); + }); + + it('does not even handshake when already abandoned', async () => { + const abandon = new AbortController(); + abandon.abort(); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('rechecks the abort after the async prehash, not just before the call', async () => { + // getProfile awaits generatePrehash(); an abort landing during that + // await would otherwise still let the adopting request go out. + const abandon = new AbortController(); + const digest = globalThis.crypto.subtle.digest as jest.Mock; + digest.mockImplementation(async () => { + abandon.abort(); + return new Uint8Array(20).fill(1).buffer; + }); + + await expect( + api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('stops the status-2 login flow when abandoned mid-way', async () => { + const abandon = new AbortController(); + sendIpcEvent + .mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } }) + .mockImplementationOnce(async () => { + abandon.abort(); + return { js: { status: 2 } }; + }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(callsByAction('do_auth')).toHaveLength(0); + }); + + it('classifies a transport auth-failure marker during get_profile', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ + stalkerAuthFailure: 'Unauthorized request.', + }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'auth-failed', + failureBody: 'Unauthorized request.', + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts new file mode 100644 index 000000000..fa5f6e020 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -0,0 +1,546 @@ +import type { DataService } from '@iptvnator/services'; +import { + extractStalkerAuthFailureBody, + STALKER_REQUEST, +} from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { + normalizeStalkerPortalIdentity, + type StalkerPortalIdentity, +} from './stalker-identity.utils'; +import { + combineStalkerPortalMessages, + StalkerPortalError, +} from './stalker-portal-error'; + +export interface StalkerHandshakeResponse { + js: { + token: string; + not_valid?: number; + random?: string; + }; +} + +export interface StalkerProfileResponse { + js: { + id?: string; + name?: string; + mac?: string; + status?: number; + msg?: string; + block_msg?: string; + /** Watchdog cadence in seconds, echoed by the portal (default 120). */ + watchdog_timeout?: number | string; + /** Per-user jitter in seconds so watchdog pings do not align. */ + timeslot?: number | string; + account_info?: { + login?: string; + expire_date?: number; + tariff_plan_name?: string; + status?: number; + }; + }; +} + +interface StalkerAuthConfirmationResponse { + js?: boolean; +} + +export interface StalkerPortalCredentials { + username?: string; + password?: string; +} + +export interface StalkerAuthenticateOptions { + /** + * Previously persisted session token. The handshake is idempotent: it is + * re-presented, and the portal returns it unchanged while it is still the + * MAC's session token. + */ + storedToken?: string; + /** Login/password for portals that answer `get_profile` with status 2. */ + credentials?: StalkerPortalCredentials; + /** + * Skip the `get_profile` round trip when the stored token came back + * unchanged — an unchanged token is already an adopted session. + */ + skipProfileWhenReused?: boolean; + /** + * Abandons the flow between portal calls. + * + * This is what a timed-out discovery attempt needs: `get_profile` is the + * call that adopts a token for the MAC portal-side, so an abandoned + * attempt reaching it AFTER another candidate authenticated would + * invalidate that healthy candidate's token. Checking the signal before + * each call stops the request from being sent, which is the only thing + * that prevents it — a request already on the wire is processed by the + * server whether or not the client tears the socket down. + */ + signal?: AbortSignal; +} + +export interface StalkerAuthenticationResult { + token: string; + accountInfo?: StalkerProfileResponse['js']['account_info']; + /** + * Raw envelope so callers can apply their own failure checks — endpoint + * discovery rejects a candidate whose `get_profile` refuses even though + * the handshake succeeded. Absent when the stored token was reused and + * no profile was fetched. + */ + profileResponse?: StalkerProfileResponse; + /** True when the stored token was accepted and `get_profile` was skipped. */ + reusedStoredToken?: boolean; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +} + +interface StalkerHandshakeOutcome { + token: string; + random: string; + /** The portal flagged the presented stored token as no longer valid. */ + notValid: boolean; +} + +/** + * SHA1 hash using native Web Crypto API + * Produces correct 40-character hex hash matching real Stalker clients + */ +async function sha1(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates SHA1 prehash from MAC address + * This must match what real Stalker clients send + */ +async function generatePrehash(macAddress: string): Promise { + // Use MAC address with colons, uppercase - this is what most clients use + const str = macAddress.toUpperCase(); + return (await sha1(str)).toUpperCase(); +} + +/** + * Generates a random string for metrics + */ +function generateRandom(): string { + const chars = 'abcdef0123456789'; + let result = ''; + for (let i = 0; i < 40; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function toFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +/** Raised when a caller abandoned the flow between portal calls. */ +export class StalkerAuthAbortedError extends Error { + constructor() { + super('Stalker authentication was aborted'); + this.name = 'StalkerAuthAbortedError'; + } +} + +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new StalkerAuthAbortedError(); + } +} + +/** + * Throws when a response is one of the portal's plain-text auth-failure + * bodies (already classified by the transport, or still raw on legacy paths). + */ +function assertNotAuthFailure(response: unknown): void { + const failureBody = extractStalkerAuthFailureBody(response); + if (failureBody) { + throw new StalkerPortalError('auth-failed', failureBody, failureBody); + } +} + +/** + * Raw Stalker authentication requests plus the documented login flow. + * + * Protocol (Stalker 4.9.35): `handshake` issues an idempotent token; + * `get_profile` turns it into a session and decodes as: full profile = OK, + * `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password + * required → `do_auth`, then `get_profile` again with `auth_second_step=1`. + */ +export class StalkerAuthApi { + constructor( + private readonly dataService: DataService, + private readonly logger: ReturnType + ) {} + + async performHandshake( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + storedToken?: string, + signal?: AbortSignal + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const prehash = await generatePrehash(macAddress); + + const params: Record = { + type: 'stb', + action: 'handshake', + // Re-presenting a persisted token is how a real client resumes a + // session: an idempotent portal returns it unchanged. + token: storedToken ?? '', + prehash, + JsHttpRequest: '1-xml', + }; + + try { + // Re-checked here rather than only at the call site: the prehash + // above is async, so an abort can land while it is being + // computed and the request would still go out. + assertNotAborted(signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + + if (response?.js?.token) { + return { + token: response.js.token, + random: response.js.random || generateRandom(), + notValid: Number(response.js.not_valid ?? 0) === 1, + }; + } + + this.logger.error('No token in response'); + throw new Error('Handshake failed: No token received'); + } catch (error) { + this.logger.error('Handshake error:', error); + throw error; + } + } + + /** + * Gets account profile information to validate the portal and check + * subscription. `authSecondStep` is set only on the retry after `do_auth`; + * `notValidToken` propagates the handshake's `not_valid` flag. + */ + async getProfile( + portalUrl: string, + macAddress: string, + token: string, + identity: StalkerPortalIdentity, + handshakeRandom: string, + options: { + authSecondStep?: boolean; + notValidToken?: boolean; + signal?: AbortSignal; + } = {} + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + // Build metrics JSON matching working app + const metrics: Record = { + mac: macAddress, + model: 'MAG250', + type: 'STB', + random: handshakeRandom, + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), + }; + + // Generate prehash for get_profile (same as handshake) + const prehash = await generatePrehash(macAddress); + + const params: Record = { + type: 'stb', + action: 'get_profile', + hd: '1', + not_valid_token: options.notValidToken ? '1' : '0', + video_out: 'hdmi', + auth_second_step: options.authSecondStep ? '1' : '0', + num_banks: '2', + metrics: JSON.stringify(metrics), + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), + ...(normalizedIdentity.deviceId1 + ? { device_id: normalizedIdentity.deviceId1 } + : {}), + ...(normalizedIdentity.deviceId2 + ? { device_id2: normalizedIdentity.deviceId2 } + : {}), + ...(normalizedIdentity.signature1 + ? { signature: normalizedIdentity.signature1 } + : {}), + ...(normalizedIdentity.signature2 + ? { signature2: normalizedIdentity.signature2 } + : {}), + prehash: prehash, + stb_type: '', + JsHttpRequest: '1-xml', + }; + + try { + // The last possible moment before the call that adopts the MAC's + // token portal-side — the prehash above is async, so the caller's + // pre-check can be stale by now. + assertNotAborted(options.signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + token, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + return response; + } catch (error) { + this.logger.error('Get profile error:', error); + throw error; + } + } + + /** + * Performs `do_auth` — the login/password step behind `get_profile` + * status 2. Returns the portal's bare boolean verdict. + */ + async doAuth( + portalUrl: string, + macAddress: string, + token: string, + credentials: StalkerPortalCredentials, + identity: StalkerPortalIdentity = {}, + signal?: AbortSignal + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const params: Record = { + type: 'stb', + action: 'do_auth', + login: credentials.username ?? '', + password: credentials.password ?? '', + ...(normalizedIdentity.deviceId1 + ? { device_id: normalizedIdentity.deviceId1 } + : {}), + ...(normalizedIdentity.deviceId2 + ? { device_id2: normalizedIdentity.deviceId2 } + : {}), + JsHttpRequest: '1-xml', + }; + + try { + assertNotAborted(signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + token, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + + // do_auth returns { js: true } on success + return response?.js === true; + } catch (error) { + this.logger.error('do_auth error:', error); + throw error; + } + } + + /** + * Full authentication flow: handshake → (optional token reuse) → + * get_profile → (status 2 → do_auth → get_profile with + * auth_second_step=1) → decoded profile. + */ + async authenticate( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + options: StalkerAuthenticateOptions = {} + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + assertNotAborted(options.signal); + const handshake = await this.performHandshake( + portalUrl, + macAddress, + normalizedIdentity, + options.storedToken, + options.signal + ); + + // An unchanged stored token is already an adopted session — the + // profile round trip is what token persistence saves. `not_valid` + // vetoes that shortcut: it is the portal saying the presented token + // is not a live session, and adopting it anyway would be + // unrecoverable — nothing writes a new token back, so every later + // start would re-present the same dead one. + if ( + options.skipProfileWhenReused && + options.storedToken && + !handshake.notValid && + handshake.token === options.storedToken + ) { + return { token: handshake.token, reusedStoredToken: true }; + } + + // The abandoned-attempt guard that matters: this is the call that + // adopts the token for the MAC portal-side. + assertNotAborted(options.signal); + const profile = await this.getProfile( + portalUrl, + macAddress, + handshake.token, + normalizedIdentity, + handshake.random, + { + authSecondStep: false, + notValidToken: handshake.notValid, + signal: options.signal, + } + ); + + // The envelope the login flow actually settled on: after a status-2 + // `do_auth` that is the RETRIED profile, not the first one. + const profileResponse = await this.resolveProfile( + profile, + portalUrl, + macAddress, + handshake, + normalizedIdentity, + options + ); + const resolved = profileResponse?.js; + + return { + token: handshake.token, + accountInfo: resolved?.account_info, + profileResponse, + reusedStoredToken: false, + watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout), + timeslotSeconds: toFiniteNumber(resolved?.timeslot), + }; + } + + /** + * Decodes `js.status` and drives the status-2 login flow. Throws a + * `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text + * when the portal refused the session. + */ + private async resolveProfile( + profile: StalkerProfileResponse, + portalUrl: string, + macAddress: string, + handshake: StalkerHandshakeOutcome, + identity: StalkerPortalIdentity, + options: StalkerAuthenticateOptions + ): Promise { + let settled = profile; + let js = profile?.js; + + // Portals routinely stringify numeric fields (the same reason + // `watchdog_timeout`/`timeslot` are typed `number | string`), so a + // strict `=== 2` would read `"2"` as a healthy profile and skip the + // whole login flow. + if (toFiniteNumber(js?.status) === 2) { + // The portal's own sentence travels WITH the refusal — "wrong + // password", "subscription expired", "contact your provider" — + // and reading it is the whole reason these errors carry + // `portalText`. It is read at each exit rather than once up + // front because `js` advances to the retry's response below: the + // second refusal explains itself, and quoting the first one back + // would describe a request that already succeeded. + const loginText = () => + combineStalkerPortalMessages(js?.msg, js?.block_msg); + const username = options.credentials?.username?.trim() ?? ''; + const password = options.credentials?.password ?? ''; + if (!username || !password) { + throw new StalkerPortalError('login-required', loginText()); + } + + assertNotAborted(options.signal); + const accepted = await this.doAuth( + portalUrl, + macAddress, + handshake.token, + { username, password }, + identity, + options.signal + ); + if (!accepted) { + // `do_auth` answers a bare `{js: false}`, so the only text + // available here is the profile's — which is the one that + // asked for the login in the first place. + throw new StalkerPortalError('login-rejected', loginText()); + } + + assertNotAborted(options.signal); + const retried = await this.getProfile( + portalUrl, + macAddress, + handshake.token, + identity, + handshake.random, + { + authSecondStep: true, + notValidToken: handshake.notValid, + signal: options.signal, + } + ); + settled = retried; + js = retried?.js; + if (toFiniteNumber(js?.status) === 2) { + throw new StalkerPortalError('login-rejected', loginText()); + } + } + + const portalText = combineStalkerPortalMessages( + js?.msg, + js?.block_msg + ); + + if (toFiniteNumber(js?.status) === 1 || portalText) { + this.logger.error('Profile error:', portalText ?? 'status 1'); + throw new StalkerPortalError('blocked', portalText); + } + + return settled; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts index 38471a5ca..a03d54a1d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -101,7 +101,12 @@ describe('StalkerPortalDiscoveryService', () => { expect(authenticate).toHaveBeenCalledWith( 'http://ministra.example/server/load.php', MAC, - { serialNumber: 'SN1' } + { serialNumber: 'SN1' }, + // Import credentials ride along for portals that answer + // get_profile with status 2 (login/password required). + // A per-attempt abort signal: a timed-out authentication must + // not send its get_profile behind the next candidate's back. + { credentials: undefined, signal: expect.any(AbortSignal) } ); }); @@ -190,7 +195,10 @@ describe('StalkerPortalDiscoveryService', () => { expect(authenticate).toHaveBeenCalledWith( 'http://gated.example/portal.php', MAC, - {} + {}, + // A per-attempt abort signal: a timed-out authentication must + // not send its get_profile behind the next candidate's back. + { credentials: undefined, signal: expect.any(AbortSignal) } ); }); @@ -343,4 +351,153 @@ describe('StalkerPortalDiscoveryService', () => { isFullStalkerPortal: false, }); }); + + it('aborts a timed-out authentication before advancing to the next candidate', async () => { + jest.useFakeTimers(); + try { + mockProbes({ + 'http://slow.example/portal.php': { + resolve: 'Authorization failed.', + }, + }); + + let captured: AbortSignal | undefined; + authenticate.mockImplementation( + (_url, _mac, _identity, options) => { + captured = options?.signal; + // Hangs past the auth budget, like the real symptom. + return new Promise(() => undefined); + } + ); + + const discovery = service.discover( + 'http://slow.example/c', + MAC + ); + // Let the probe resolve so the auth attempt actually starts. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.advanceTimersByTime(65_000); + await Promise.resolve(); + + // The abandoned attempt is cancelled, so its get_profile never + // goes out to adopt the MAC's token behind a later candidate. + expect(captured?.aborted).toBe(true); + + // …and discovery does not RACE it: a request already dispatched + // cannot be un-sent, so the run drains it (bounded) instead of + // probing the next candidate while it may still land. + jest.advanceTimersByTime(15_000); + // Outcome shape is unchanged: a timed-out confirmation is still + // reported as a refusal of that endpoint. + await expect(discovery).resolves.toMatchObject({ + status: 'auth-rejected', + portalUrl: 'http://slow.example/portal.php', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('drains an abandoned attempt instead of probing the next candidate', async () => { + jest.useFakeTimers(); + try { + // Two candidates both answer "auth required", so a resolved first + // attempt would stop the run — only a timed-out one advances. + mockProbes({ + 'http://slow.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://slow.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + + let settleFirst: (() => void) | undefined; + authenticate + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + settleFirst = () => reject(new Error('late')); + }) + ) + .mockResolvedValue({ token: 'SECOND' }); + + const discovery = service.discover('http://slow.example/c', MAC); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + jest.advanceTimersByTime(65_000); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + // The first attempt is still on the wire: the second candidate + // must NOT have been authenticated yet, or its token could be + // invalidated by the first one's late get_profile. + expect(authenticate).toHaveBeenCalledTimes(1); + + // Once it settles, the run continues immediately. + settleFirst?.(); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + expect(authenticate).toHaveBeenCalledTimes(2); + await expect(discovery).resolves.toMatchObject({ + status: 'resolved', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('stops discovery when the drain deadline expires with the attempt still live', async () => { + jest.useFakeTimers(); + try { + // Cancellation is cooperative: neither transport can pull a + // request off the wire. An attempt still unsettled 80 s in may + // yet land its `get_profile`, which adopts the MAC's token + // portal-side — so probing on would stake the next candidate's + // freshly issued session on a request nobody can recall. + mockProbes({ + 'http://hung.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://hung.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + + // Never settles — not even after the drain. + authenticate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValue({ token: 'SECOND' }); + + const discovery = service.discover('http://hung.example/c', MAC); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + jest.advanceTimersByTime(65_000); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + + await expect(discovery).resolves.toMatchObject({ + status: 'auth-rejected', + abandonedInFlight: true, + }); + // The second candidate was never authenticated. + expect(authenticate).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts index 0764d747a..009c1c8e5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services'; import { STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; import { + StalkerPortalCredentials, StalkerProfileResponse, StalkerSessionService, } from './stalker-session.service'; @@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution { token?: string; /** Account block from the classification `get_profile` (full portals only). */ accountInfo?: StalkerProfileResponse['js']['account_info']; + /** + * Watchdog cadence the confirming `get_profile` advertised. Persisted at + * import because a later start reuses the token and skips the only + * response that carries it. + */ + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; } /** @@ -37,6 +45,16 @@ export interface StalkerPortalDiscoveryRejection { status: 'auth-rejected'; portalUrl: string; error?: unknown; + /** + * The abandoned attempt was STILL in flight when the drain deadline + * expired, so discovery must not advance. Cancellation is cooperative — + * neither transport can pull a request off the wire (the PWA `fetch()` + * takes no signal, and the Electron main process runs its HTTP request to + * completion) — so an attempt this far past its deadline may still land a + * `get_profile`, which adopts the MAC's token portal-side and would + * invalidate the session a later candidate had just established. + */ + abandonedInFlight?: boolean; } /** No candidate answered like a Stalker portal (host down or not a portal). */ @@ -51,15 +69,42 @@ export type StalkerPortalDiscoveryOutcome = /** Per-request guard so a hanging host cannot stall discovery forever. */ const PROBE_TIMEOUT_MS = 20_000; -/** authenticate() is two sequential requests; give it a matching budget. */ -const AUTH_TIMEOUT_MS = 45_000; +/** + * `authenticate()` is up to FOUR sequential requests once a portal answers + * `get_profile` with status 2 — handshake, profile, `do_auth`, profile retry — + * and the Electron transport allows each non-`create_link` call 15 s. A budget + * that only covered two would abort a valid but slow login-required portal + * before its final profile and report it as `auth-rejected`. + */ +const AUTH_TIMEOUT_MS = 4 * 15_000 + 5_000; -function withTimeout(promise: Promise, timeoutMs: number): Promise { +/** + * How long to wait for an abandoned attempt to settle before probing the next + * candidate. + * + * Aborting cannot un-send a request: if its `get_profile` was already + * dispatched, the portal adopts that token regardless of what the client does + * to its socket. What we CAN do is refuse to race it — advancing while it is + * still in flight is what lets it invalidate the token the next candidate + * negotiates. Bounded by one request budget so a genuinely hung host cannot + * stall discovery forever; past that the risk is accepted rather than hanging. + */ +const ABANDONED_DRAIN_MS = 15_000; + +function withTimeout( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void +): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Stalker portal probe timed out')), - timeoutMs - ); + const timer = setTimeout(() => { + // Abandon the underlying operation BEFORE advancing: the timer + // only rejects this wrapper, and a late `get_profile` would adopt + // the MAC's token portal-side, invalidating whatever the next + // candidate just negotiated. + onTimeout?.(); + reject(new Error('Stalker portal probe timed out')); + }, timeoutMs); promise.then( (value) => { clearTimeout(timer); @@ -95,7 +140,8 @@ export class StalkerPortalDiscoveryService { async discover( rawUrl: string, macAddress: string, - identity: StalkerPortalIdentity = {} + identity: StalkerPortalIdentity = {}, + options: { credentials?: StalkerPortalCredentials } = {} ): Promise { const candidates = buildStalkerEndpointCandidates(rawUrl); let authRejection: StalkerPortalDiscoveryRejection | null = null; @@ -114,11 +160,15 @@ export class StalkerPortalDiscoveryService { const outcome = await this.confirmFullPortal( candidate, macAddress, - identity + identity, + options.credentials ); if (outcome.status === 'resolved') { return outcome; } + if (outcome.abandonedInFlight) { + return authRejection ?? outcome; + } authRejection = authRejection ?? outcome; continue; } @@ -158,11 +208,17 @@ export class StalkerPortalDiscoveryService { const outcome = await this.confirmFullPortal( candidate, macAddress, - identity + identity, + options.credentials ); if (outcome.status === 'resolved') { return outcome; } + // An attempt still on the wire outranks further probing: + // see `abandonedInFlight`. + if (outcome.abandonedInFlight) { + return authRejection ?? outcome; + } // The endpoint is real but refused our credentials; // remember the first such endpoint in case no later // candidate resolves. @@ -184,18 +240,25 @@ export class StalkerPortalDiscoveryService { private async confirmFullPortal( candidate: string, macAddress: string, - identity: StalkerPortalIdentity + identity: StalkerPortalIdentity, + credentials?: StalkerPortalCredentials ): Promise< StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection > { + // Cooperative cancellation: `authenticate()` checks this before each + // portal call, so a timed-out attempt never sends the `get_profile` + // that would adopt the MAC's token behind the next candidate's back. + const abandon = new AbortController(); + // Kept so a timed-out attempt can be drained rather than raced. + const pending = this.stalkerSession.authenticate( + candidate, + macAddress, + identity, + { credentials, signal: abandon.signal } + ); try { - const auth = await withTimeout( - this.stalkerSession.authenticate( - candidate, - macAddress, - identity - ), - AUTH_TIMEOUT_MS + const auth = await withTimeout(pending, AUTH_TIMEOUT_MS, () => + abandon.abort() ); // A handshake can hand out a token whose `get_profile` still // answers a structured denial (`{js:{error:'Invalid token'}}`); @@ -215,12 +278,41 @@ export class StalkerPortalDiscoveryService { isFullStalkerPortal: true, token: auth.token, accountInfo: auth.accountInfo, + watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds, + timeslotSeconds: auth.timeslotSeconds, }; } catch (error) { + // Do not advance while the abandoned attempt may still be on the + // wire: its `get_profile` adopts the MAC's token portal-side, so + // racing it is exactly what invalidates the next candidate's + // freshly issued session. + // + // Draining is the normal case and usually returns at once — an + // aborted attempt settles as soon as its in-flight request errors + // out. The deadline exists for the attempt that does not settle, + // and reaching it is reported rather than swallowed: continuing + // would stake a working candidate's session on a request nobody + // can recall. + const DRAINED = Symbol('drained'); + const outcome = await Promise.race([ + pending.then( + () => DRAINED, + () => DRAINED + ), + new Promise((resolve) => + setTimeout(resolve, ABANDONED_DRAIN_MS) + ), + ]); + if (outcome !== DRAINED) { + this.logger.warn( + 'Abandoned Stalker authentication is still in flight after the drain deadline; stopping discovery rather than racing it' + ); + } return { status: 'auth-rejected', portalUrl: candidate, error, + ...(outcome === DRAINED ? {} : { abandonedInFlight: true }), }; } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts new file mode 100644 index 000000000..f09ea54fd --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts @@ -0,0 +1,55 @@ +import { + asStalkerPortalError, + combineStalkerPortalMessages, + StalkerPortalError, + stripStalkerPortalMarkup, +} from './stalker-portal-error'; + +describe('stripStalkerPortalMarkup', () => { + it('strips the markup a real block_msg carries', () => { + expect( + stripStalkerPortalMarkup( + 'Your STB is damaged.
Call the provider.' + ) + ).toBe('Your STB is damaged. Call the provider.'); + }); + + it('collapses whitespace and trims', () => { + expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b'); + }); +}); + +describe('combineStalkerPortalMessages', () => { + it('joins msg and block_msg', () => { + expect( + combineStalkerPortalMessages('device conflict', 'STB damaged') + ).toBe('device conflict — STB damaged'); + }); + + it('drops empty and duplicated parts', () => { + expect(combineStalkerPortalMessages('', undefined)).toBeUndefined(); + expect(combineStalkerPortalMessages('same', 'same')).toBe('same'); + }); +}); + +describe('asStalkerPortalError', () => { + it('recognizes real instances', () => { + const error = new StalkerPortalError('blocked', 'text'); + expect(asStalkerPortalError(error)).toBe(error); + }); + + it('recognizes a structurally equivalent object across chunk boundaries', () => { + const shaped = { + name: 'StalkerPortalError', + kind: 'login-required', + message: 'refused', + }; + expect(asStalkerPortalError(shaped)?.kind).toBe('login-required'); + }); + + it('rejects ordinary errors and non-errors', () => { + expect(asStalkerPortalError(new Error('nope'))).toBeNull(); + expect(asStalkerPortalError('Access denied.')).toBeNull(); + expect(asStalkerPortalError(null)).toBeNull(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts new file mode 100644 index 000000000..d457b834d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts @@ -0,0 +1,93 @@ +import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces'; + +/** + * Why a Stalker portal refused the session. + * + * - `login-required` — `get_profile` answered `status: 2`: the portal wants + * `do_auth` with a login/password, and none are stored for the playlist. + * - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing + * script refused the credentials), or the profile still demanded a login + * after a successful `do_auth`. + * - `blocked` — `get_profile` answered `status: 1`: the account is blocked or + * the device identity conflicts. `msg`/`block_msg` explain why. + * - `auth-failed` — a request came back as one of the plain-text bodies + * (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and + * re-authentication did not recover it. + */ +export type StalkerPortalErrorKind = + | 'login-required' + | 'login-rejected' + | 'blocked' + | 'auth-failed'; + +/** + * `block_msg` routinely carries markup ("Your STB is damaged.
Call the + * provider."); strip it before the text reaches a snackbar or error view. + */ +export function stripStalkerPortalMarkup(text: string): string { + return text + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Combines the portal's own `msg`/`block_msg` explanation into one plain-text + * line, or returns undefined when the portal sent none. + */ +export function combineStalkerPortalMessages( + msg: string | undefined, + blockMsg: string | undefined +): string | undefined { + const parts = [msg, blockMsg] + .map((part) => stripStalkerPortalMarkup(part ?? '')) + .filter((part) => part.length > 0); + // A duplicated msg/block_msg pair should not be echoed twice. + const unique = parts.filter( + (part, index) => parts.indexOf(part) === index + ); + return unique.length > 0 ? unique.join(' — ') : undefined; +} + +/** + * A portal-explained authentication failure. `portalText` carries the server's + * own words (already markup-stripped) when it sent any — the UI shows them + * verbatim instead of a generic "unable to load" message. + */ +export class StalkerPortalError extends Error { + constructor( + readonly kind: StalkerPortalErrorKind, + readonly portalText?: string, + readonly failureBody?: StalkerAuthFailureBody + ) { + super( + `Stalker portal refused the session (${kind})` + + (portalText ? `: ${portalText}` : '') + ); + this.name = 'StalkerPortalError'; + } +} + +/** + * Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is + * checked first, but a structurally equivalent object (same name + kind) is + * accepted too so lazy-loaded consumers never mis-classify one. + */ +export function asStalkerPortalError( + error: unknown +): StalkerPortalError | null { + if (error instanceof StalkerPortalError) { + return error; + } + + if ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'StalkerPortalError' && + typeof (error as { kind?: unknown }).kind === 'string' + ) { + return error as StalkerPortalError; + } + + return null; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index fd99719f3..27e066a7f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => { /** When set, the atomic write fails AFTER the transform verified. */ let persistError: Error | null; let setCachedToken: jest.Mock; + let adoptDiscoveredSession: jest.Mock; let clearCachedToken: jest.Mock; let refreshActiveWatchdogPlaylist: jest.Mock; @@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => { return of(next); }); setCachedToken = jest.fn(); + adoptDiscoveredSession = jest.fn(); clearCachedToken = jest.fn(); refreshActiveWatchdogPlaylist = jest.fn(); @@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => { provide: StalkerSessionService, useValue: { setCachedToken, + adoptDiscoveredSession, clearCachedToken, refreshActiveWatchdogPlaylist, }, @@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://ministra.example/server/load.php', isFullStalkerPortal: true, }); - // The classification handshake already produced a token, - // tagged with the playlist as its identity source. - expect(setCachedToken).toHaveBeenCalledWith( + // The classification handshake already produced a session; it is + // adopted whole (token + cadence), tagged with the REPAIRED + // configuration as its identity source. + expect(adoptDiscoveredSession).toHaveBeenCalledWith( 'portal-1', - 'TOKEN1', - expect.objectContaining({ _id: 'portal-1' }) + expect.objectContaining({ _id: 'portal-1' }), + expect.objectContaining({ token: 'TOKEN1' }) ); // A repaired ACTIVE playlist must re-sync the watchdog now: a // simple→full flip has to start the keepalive mid-session. @@ -282,6 +286,82 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it('discards a repair whose credentials changed while probing', async () => { + // Discovery can run for tens of seconds; a login saved meanwhile + // means the outcome was negotiated for an account the row no + // longer belongs to, so committing it would adopt the wrong + // session. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'WRONG-ACCOUNT', + }); + // The row the transform sees carries the NEW login. + persistedRow = { + ...MISCLASSIFIED, + username: 'edited-mid-probe', + } as Playlist; + + expect( + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'original', + }) + ).toBeNull(); + expect(writtenRow).toBeNull(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); + }); + + it('adopts the cadence the repair confirmation discovered', async () => { + // Caching the token alone satisfies the retry, so NO + // authentication path would ever apply the profile outcome — the + // repaired playlist would keep the default cadence until the + // token failed or the app restarted. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + }); + + await service.repairPortal(MISCLASSIFIED); + + expect(adoptDiscoveredSession).toHaveBeenCalledWith( + MISCLASSIFIED._id, + expect.objectContaining({ + portalUrl: 'http://panel.example/server/load.php', + }), + { + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + } + ); + }); + + it("forwards the playlist's stored credentials to discovery", async () => { + // A login/password portal answers status 2 during confirmation; + // without the credentials the probe reports `login-required` and + // the source could never be repaired. + discover.mockResolvedValue({ status: 'unreachable' }); + + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'user', + password: 'secret', + }); + + expect(discover).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Object), + { credentials: { username: 'user', password: 'secret' } } + ); + }); + it('probes at most once per playlist per session', async () => { discover.mockResolvedValue({ status: 'unreachable' }); @@ -438,7 +518,7 @@ describe('StalkerPortalRepairService', () => { expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); expect(writtenRow).toBeNull(); - expect(setCachedToken).not.toHaveBeenCalled(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index f72bff60c..617bddf77 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { playlist.portalUrl ?? '', isFullStalkerPortalPlaylist(playlist), stalkerIdentityFingerprint(playlist), + // Credentials are part of the discovery outcome now: a probe that + // failed on a wrong login must be retried once the login is + // corrected, instead of staying declined until the app restarts. + playlist.username ?? '', + playlist.password ?? '', ]); } @@ -336,7 +341,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { const outcome = await this.discovery.discover( playlist.portalUrl ?? '', playlist.macAddress ?? '', - getStalkerPortalIdentityFromPlaylist(playlist) + getStalkerPortalIdentityFromPlaylist(playlist), + { + // A login/password portal answers `get_profile` with status 2 + // during confirmation. Without the stored credentials the + // probe reports `login-required` and such a source could + // never be repaired, even though the playlist holds a + // working login. + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); if (outcome.status !== 'resolved') { @@ -416,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { ?.set(this.repairSourceFingerprint(playlist), override); if (outcome.isFullStalkerPortal && outcome.token) { - // The classification handshake already authenticated; reuse its - // token so the retry does not immediately handshake again. The - // playlist itself is the identity source — a repair never - // changes WHO the session belongs to, only WHERE it talks. - this.stalkerSession.setCachedToken( + // The classification handshake already authenticated; adopt the + // whole session so the retry does not handshake again AND the + // cadence that profile advertised is applied — caching the token + // alone would satisfy the retry and leave the repaired playlist + // pinging on the default until restart. The identity source is + // the REPAIRED configuration: a repair never changes WHO the + // session belongs to, only WHERE it talks, and the session is + // bound to that endpoint. + this.stalkerSession.adoptDiscoveredSession( playlist._id, - outcome.token, - playlist + toStalkerSessionPlaylist(this.applyOverride(playlist)), + { + token: outcome.token, + watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds, + timeslotSeconds: outcome.timeslotSeconds, + } ); } else if (!outcome.isFullStalkerPortal) { this.stalkerSession.clearCachedToken(playlist._id); @@ -483,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { row.portalUrl === playlist.portalUrl && isFullStalkerPortalPlaylist(row) === sourceMode && stalkerIdentityFingerprint(row) === - stalkerIdentityFingerprint(playlist) + stalkerIdentityFingerprint(playlist) && + // Credentials too, matching repairSourceFingerprint(): discovery + // can run for tens of seconds, and a login saved meanwhile means + // the outcome was negotiated for an account the row no longer + // belongs to — committing it would adopt the wrong session. + (row.username ?? '') === (playlist.username ?? '') && + (row.password ?? '') === (playlist.password ?? '') ); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts new file mode 100644 index 000000000..15b7c2101 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts @@ -0,0 +1,48 @@ +import { + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, +} from '@iptvnator/shared/interfaces'; + +/** + * Checks whether a response or thrown error indicates a Stalker authorization + * failure. + * + * Every signal here is structural. `isStalkerAuthFailureResponse` covers the + * complete set of portal auth failures — the plain-text bodies, the transport + * marker Electron mints from them, and the JSON-envelope forms — and is + * shared with endpoint discovery and the lazy repair, so a phrase one layer + * classifies as an auth failure cannot be ignored by another: the session + * would otherwise keep an expired token and every later request fails. + * + * What is deliberately gone: the old `JSON.stringify(response)` regex sweep. + * It matched the phrase anywhere in an arbitrary payload — including inside + * legitimate catalog data — and existed only because the raw body reached the + * renderer unclassified. The transports classify now, so the shapes are known. + */ +export function isStalkerAuthorizationFailure( + responseOrError: unknown +): boolean { + if (!responseOrError) { + return false; + } + + const response = responseOrError as Record; + const message = response?.['message']; + + if ( + isStalkerAuthFailureResponse(responseOrError) || + isStalkerAuthFailureMessage(message) + ) { + return true; + } + + // HTTP auth codes survive the IPC boundary only as message text + // (`HTTP Error 401: …`): the custom `status` property is stripped by + // ipcRenderer, so the numeric code must be read from the message. + if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) { + return true; + } + + // Same code on a response object that never crossed IPC. + return response?.['status'] === 401 || response?.['status'] === 403; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts new file mode 100644 index 000000000..01f311404 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -0,0 +1,266 @@ +import { firstValueFrom } from 'rxjs'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import type { PlaylistsService } from '@iptvnator/services'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerIdentityFingerprint } from './stalker-identity.utils'; +import type { StalkerAuthenticationResult } from './stalker-auth.api'; + +/** + * Everything a Stalker session is bound to: the endpoint it was negotiated + * against, the device identity, and the account credentials. + * + * All three halves matter, and each was a real defect when missing: + * + * - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a + * playlist repointed at another portal would disclose the previous one's + * bearer token to it. Origin alone is not enough: discovery deliberately + * preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are + * different portals on one host. + * - **Identity** — an edited MAC/serial must not inherit the old session. + * - **Credentials** — for a status-2 portal the login decides which account + * the token represents, so changing it must not keep serving the previous + * account's session. + * + * Used for BOTH the in-run cache and the persisted session: an edit applies + * without waiting for a restart. + */ +export function stalkerSessionFingerprint( + playlist: Pick< + Playlist, + | 'portalUrl' + | 'macAddress' + | 'username' + | 'password' + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' + > +): string { + return JSON.stringify([ + portalEndpoint(playlist.portalUrl), + stalkerIdentityFingerprint(playlist as Playlist), + playlist.username ?? '', + playlist.password ?? '', + ]); +} + +/** + * Origin AND path, minus query/fragment — the endpoint a session belongs to. + * + * No attempt is made to treat different handler paths for the same portal + * (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the + * comparison read the SAME persisted `portalUrl`, so the same portal always + * yields the same key; the value changes only on a real edit or repair, and + * both of those re-authenticate anyway. An equivalence rule would add + * machinery whose only failure mode is aliasing two genuinely different + * endpoints — the exact thing this key exists to prevent. + */ +function portalEndpoint(portalUrl: string | undefined): string { + if (!portalUrl) { + return ''; + } + try { + const parsed = new URL(portalUrl); + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + } catch { + // Unparseable: fall back to the raw string so a change is still a + // change — never to a constant, which would alias every endpoint. + return portalUrl; + } +} + +/** + * Session facts persisted with the playlist between app starts. + * + * `identityFingerprint` is what makes the token safe to re-present: it records + * the identity the session was negotiated for, so an edited MAC or serial + * cannot inherit the previous session. + */ +export interface PersistedStalkerSession { + token?: string; + identityFingerprint?: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +} + +/** + * Reads and writes the Stalker session persisted on the playlist row. + * + * Kept out of the session service because it is the only part that needs the + * persistence stack, which is resolved lazily: the service is constructed + * during bootstrap, and pulling `PlaylistsService` in eagerly would both + * widen its dependency graph and risk a DI cycle. + */ +export class StalkerSessionStore { + constructor( + private readonly resolvePlaylistsService: () => PlaylistsService, + private readonly logger: ReturnType + ) {} + + /** Reads a playlist row through the lazily-resolved persistence stack. */ + async readRow(playlistId: string): Promise { + const row = await firstValueFrom( + this.resolvePlaylistsService().getPlaylistById(playlistId) + ); + return (row as Playlist) ?? undefined; + } + + /** + * Reads the session persisted with the playlist: the token, the identity + * it was negotiated for, and the watchdog cadence the portal advertised. + * + * The token is only offered for reuse when its identity still matches — + * re-presenting one minted for an edited MAC would pair a new identity + * with an old session, exactly what the in-memory cache guards against. + */ + async read( + playlist: Playlist, + fingerprint: string + ): Promise { + const fromPlaylist: PersistedStalkerSession = { + token: playlist.stalkerToken || undefined, + identityFingerprint: playlist.stalkerSessionIdentity, + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }; + + // Shortcut only when the object carries the cadence too. Taking it on + // the token alone would silently drop a persisted cadence for any + // playlist shape that copies the token without the timing fields. + const stored = + fromPlaylist.token && + fromPlaylist.watchdogTimeoutSeconds !== undefined + ? fromPlaylist + : await this.readFromRow(playlist, fromPlaylist); + + // A token with NO recorded fingerprint is unverified, not trusted: + // playlists written before the fingerprint existed carry one, and + // re-presenting it after an endpoint or identity edit is exactly the + // disclosure the fingerprint prevents. Such a row has no cadence + // either, so it already owes a full profile — refusing the token + // costs it nothing, and the write-back then records the fingerprint. + if (stored.token && stored.identityFingerprint !== fingerprint) { + return { ...stored, token: undefined }; + } + + return stored; + } + + private async readFromRow( + playlist: Playlist, + fallback: PersistedStalkerSession + ): Promise { + try { + const stored = await this.readRow(playlist._id); + return { + // The row is authoritative, but a meta that carried a token + // the row has not seen yet must not lose it. + token: stored?.stalkerToken || fallback.token, + identityFingerprint: + stored?.stalkerSessionIdentity ?? + fallback.identityFingerprint, + watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout, + timeslotSeconds: stored?.stalkerTimeslot, + }; + } catch (error) { + this.logger.debug('Persisted session lookup failed:', error); + return fallback; + } + } + + /** + * Propagates a successful authentication into session-side state: the + * watchdog cadence, and the write-back. + * + * Reusing a stored token skips the `get_profile` that carries the + * cadence, so the persisted cadence is applied instead — otherwise a + * portal advertising a non-default `watchdog_timeout` would sit on the + * 120 s fallback for the whole session. + */ + applyAuthenticationOutcome( + playlistId: string, + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string, + watchdog: { + applyProfileTiming: ( + playlistId: string, + timing: { + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; + } + ) => void; + } + ): void { + if (result.reusedStoredToken) { + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds, + timeslotSeconds: stored.timeslotSeconds, + }); + return; + } + + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: result.watchdogTimeoutSeconds, + timeslotSeconds: result.timeslotSeconds, + }); + this.write(playlistId, result, stored, fingerprint); + } + + /** + * Writes a renegotiated session back, best effort. + * + * The EFFECTIVE cadence is stored, not the raw one: a portal that + * advertises nothing must still leave a value behind, or "no cadence + * stored" would keep meaning "never profiled" and every start would + * re-profile it. Stored absence therefore means exactly one thing — this + * playlist has never completed a profile. + * + * Returns the cadence that was applied, so the caller can drive the + * watchdog with the same numbers. + */ + write( + playlistId: string, + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string + ): void { + const watchdogTimeout = + result.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS; + const timeslot = result.timeslotSeconds ?? 0; + const changed = + result.token !== stored.token || + fingerprint !== stored.identityFingerprint || + watchdogTimeout !== stored.watchdogTimeoutSeconds || + timeslot !== stored.timeslotSeconds; + + if (!changed) { + return; + } + + try { + void firstValueFrom( + this.resolvePlaylistsService().updateStalkerSession( + playlistId, + { + stalkerToken: result.token, + stalkerSessionIdentity: fingerprint, + stalkerWatchdogTimeout: watchdogTimeout, + stalkerTimeslot: timeslot, + } + ) + ).catch((error) => { + this.logger.debug('Session write-back failed:', error); + }); + } catch (error) { + // Persistence stack unavailable (e.g. isolated tests, or a DI + // cycle at this point in bootstrap). The session still works for + // this run; only the cross-restart reuse is lost. + this.logger.debug('Session write-back unavailable:', error); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index bb4540b2a..2b2846b64 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; +import { StalkerPortalError } from './stalker-portal-error'; +import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerSessionFingerprint } from './stalker-session-store'; import { STALKER_SERIAL_NUMBER, StalkerProfileResponse, @@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => { ); const oldAuth = service.ensureToken(playlistA); - for (let i = 0; i < 5; i += 1) { - await Promise.resolve(); + // ensureToken reads the persisted session before the handshake, so + // wait for the transport call rather than a fixed microtask count. + while (pendingResolvers.length === 0) { + await new Promise((resolve) => setTimeout(resolve)); } const editedIdentity = { @@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => { // Settle the OLD identity's handshake + profile. pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } }); - for (let i = 0; i < 10; i += 1) { - await Promise.resolve(); + while (pendingResolvers.length < 2) { + await new Promise((resolve) => setTimeout(resolve)); } - pendingResolvers[1]?.({ js: {} }); + pendingResolvers[1]({ js: {} }); await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' }); // The edited identity re-enters and negotiates its OWN session. @@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => { service.setCachedToken('portal-1', 'DEAD', playlistA); sendIpcEvent.mockResolvedValue('Authorization failed.'); + // The typed refusal replaced the generic "Authorization failed after + // retry": callers need the portal's own reason to show the user. await expect( service.makeAuthenticatedRequest( playlistA, { action: 'get_events' }, false ) - ).rejects.toThrow('Authorization failed after retry'); + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'auth-failed', + failureBody: 'Authorization failed.', + }); // Leaving the dead token cached would hand it to the next caller. expect(service.getCachedToken('portal-1')).toBeNull(); @@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => { let service: StalkerSessionService; let dataService: { sendIpcEvent: jest.Mock }; + let playlistsService: { + getPlaylistById: jest.Mock; + updateStalkerSession: jest.Mock; + }; beforeEach(() => { Object.defineProperty(globalThis, 'crypto', { @@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => { dataService = { sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }), }; + playlistsService = { + getPlaylistById: jest.fn().mockReturnValue(of(undefined)), + updateStalkerSession: jest.fn().mockReturnValue(of(null)), + }; TestBed.configureTestingModule({ providers: [ StalkerSessionService, { provide: DataService, useValue: dataService }, + { provide: PlaylistsService, useValue: playlistsService }, ], }); @@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => { stalkerSignature2: 'SIGNATURE-2', } as Playlist); - expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, { - serialNumber: 'CUSTOMSN123', - deviceId1: 'DEVICE-ID-1', - deviceId2: 'DEVICE-ID-2', - signature1: 'SIGNATURE-1', - signature2: 'SIGNATURE-2', - }); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + { + serialNumber: 'CUSTOMSN123', + deviceId1: 'DEVICE-ID-1', + deviceId2: 'DEVICE-ID-2', + signature1: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + }, + // No cadence stored for this playlist, so the profile must run. + expect.objectContaining({ skipProfileWhenReused: false }) + ); }); it('treats the legacy default serial number as absent during ensureToken', async () => { @@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => { } as Playlist); expect(result.serialNumber).toBeUndefined(); - expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {}); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ skipProfileWhenReused: false }) + ); }); it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => { @@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => { const refresh = service.refreshAccountProfile(playlist); // Two handshakes must never overlap: on strict portals the second - // would invalidate the first one's token. - await Promise.resolve(); + // would invalidate the first one's token. (Macrotask flush: the + // persisted-token lookup adds awaits before authenticate fires.) + await new Promise((resolve) => setTimeout(resolve)); expect(authenticate).toHaveBeenCalledTimes(1); releaseFirst({ token: 'session-token' }); @@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => { const first = service.refreshAccountProfile(playlist); const second = service.refreshAccountProfile(playlist); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve)); expect(authenticate).toHaveBeenCalledTimes(1); releases[0]({ token: 'session-token' }); @@ -708,6 +740,581 @@ describe('StalkerSessionService identity payloads', () => { expect(accountInfo).toEqual({ login: 'user-2' }); }); + it('re-presents the token persisted on the playlist during ensureToken', async () => { + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'STORED-TOKEN' }); + + await service.ensureToken({ + _id: 'playlist-7', + portalUrl, + macAddress, + isFullStalkerPortal: true, + stalkerToken: 'STORED-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), + // With the cadence present the playlist is self-sufficient, so no + // row read is needed. + stalkerWatchdogTimeout: 120, + } as Playlist); + + expect(playlistsService.getPlaylistById).not.toHaveBeenCalled(); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }) + ); + }); + + it('falls back to the stored playlist row for the persisted token', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-8', + stalkerToken: 'ROW-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), + stalkerWatchdogTimeout: 120, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true }); + + await service.ensureToken({ + _id: 'playlist-8', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + 'playlist-8' + ); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: 'ROW-TOKEN' }) + ); + // A reused token was not renegotiated — nothing to write back. + expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled(); + }); + + it('writes a newly negotiated token back to the playlist', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'NEW-TOKEN', + reusedStoredToken: false, + }); + + await service.ensureToken({ + _id: 'playlist-9', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-9', + expect.objectContaining({ stalkerToken: 'NEW-TOKEN' }) + ); + }); + + it('applies the persisted watchdog cadence when the token is reused', async () => { + // Reuse skips the get_profile that carries the cadence, so without + // the persisted values the watchdog would sit on its 120 s default + // for the whole session — for a portal that advertised 60 s that is + // slower than before this feature existed. + const watchdog = ( + service as unknown as { + watchdog: { applyProfileTiming: jest.Mock }; + } + ).watchdog; + jest.spyOn(watchdog, 'applyProfileTiming'); + + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-12', + stalkerToken: 'REUSED', + stalkerWatchdogTimeout: 60, + stalkerTimeslot: 7, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'REUSED', + reusedStoredToken: true, + }); + + await service.ensureToken({ + _id: 'playlist-12', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(watchdog.applyProfileTiming).toHaveBeenCalledWith( + 'playlist-12', + { watchdogTimeoutSeconds: 60, timeslotSeconds: 7 } + ); + }); + + it('profiles a legacy token-only playlist and refuses its unverified token', async () => { + // A playlist written before this change has a token but no recorded + // fingerprint, so nothing proves which endpoint/identity it belongs + // to — re-presenting it after an edit is the disclosure the + // fingerprint exists to prevent. It owes a full profile anyway (no + // cadence), so refusing the token costs nothing and the write-back + // then records the fingerprint. + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ + token: 'LEGACY', + reusedStoredToken: false, + watchdogTimeoutSeconds: 60, + timeslotSeconds: 5, + }); + + await service.ensureToken({ + _id: 'playlist-16', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: undefined, + skipProfileWhenReused: false, + }) + ); + // ...and the learned cadence is persisted, so the next start skips. + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-16', + expect.objectContaining({ + stalkerWatchdogTimeout: 60, + stalkerTimeslot: 5, + }) + ); + }); + + it('records the effective cadence when the portal advertises none', async () => { + // Otherwise "no cadence stored" would keep meaning "never profiled" + // and such a portal would be re-profiled on every single start. + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'LEGACY', + reusedStoredToken: false, + }); + + await service.ensureToken({ + _id: 'playlist-17', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-17', + expect.objectContaining({ + stalkerWatchdogTimeout: + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + stalkerTimeslot: 0, + }) + ); + }); + + it('refuses a cached and persisted session after the login changed', async () => { + // For a status-2 portal the login decides WHICH account the token + // represents, so serving the old session would keep the user on the + // previous account indefinitely. + const before = { + _id: 'playlist-login-change', + portalUrl, + macAddress, + isFullStalkerPortal: true, + username: 'old-user', + password: 'old-pass', + } as Playlist; + service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before); + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...before, + stalkerToken: 'OLD-ACCOUNT-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(before), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'NEW', reusedStoredToken: false }); + + const result = await service.ensureToken({ + ...before, + username: 'new-user', + password: 'new-pass', + } as Playlist); + + // Neither the in-run cache nor the persisted token is reused. + expect(result.token).toBe('NEW'); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: undefined, + credentials: { username: 'new-user', password: 'new-pass' }, + }) + ); + }); + + it('refuses a persisted token across tenants on the SAME host', async () => { + // Discovery deliberately preserves tenant base paths, so two portals + // can share an origin. An origin-only key would send tenant A's + // bearer to tenant B. + const tenantA = { + _id: 'playlist-tenant', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...tenantA, + stalkerToken: 'TENANT-A-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(tenantA), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...tenantA, + portalUrl: 'https://panel.example.com/tenant-b/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://panel.example.com/tenant-b/server/load.php', + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('keeps the session for the same endpoint spelled with a trailing slash', async () => { + // Normalisation must not turn a cosmetic difference into a forced + // re-authentication. + const portal = { + _id: 'playlist-slash', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + expect( + stalkerSessionFingerprint({ + ...portal, + portalUrl: 'https://panel.example.com/tenant-a/server/load.php/', + } as Playlist) + ).toBe(stalkerSessionFingerprint(portal)); + }); + + it('refuses a persisted token when the playlist was repointed at another host', async () => { + // ensureToken re-presents persisted tokens in a handshake, so an + // identity-only check would disclose the previous portal's bearer + // token to an unrelated server. + const original = { + _id: 'playlist-moved', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...original, + stalkerToken: 'OLD-HOST-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(original), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...original, + portalUrl: 'https://new.example.com/stalker_portal/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://new.example.com/stalker_portal/server/load.php', + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('refuses a persisted token minted for a different identity', async () => { + // The playlist was edited after the token was issued. Re-presenting + // it would pair the new identity with the old session — the same bug + // class the in-memory cache guards against, just across a restart. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-identity', + stalkerToken: 'OLD-IDENTITY-TOKEN', + stalkerSessionIdentity: 'not-the-current-fingerprint', + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + _id: 'playlist-identity', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('reuses a persisted token whose identity still matches', async () => { + const playlist = { + _id: 'playlist-identity-ok', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...playlist, + stalkerToken: 'SAME-IDENTITY-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(playlist), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ + token: 'SAME-IDENTITY-TOKEN', + reusedStoredToken: true, + }); + + await service.ensureToken(playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' }) + ); + }); + + it('reads the stored row when a token arrives without its cadence', async () => { + // A playlist shape that copies the token but not the timing fields + // must not silently downgrade the portal to the 120 s default. + const watchdog = ( + service as unknown as { + watchdog: { applyProfileTiming: jest.Mock }; + } + ).watchdog; + jest.spyOn(watchdog, 'applyProfileTiming'); + + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-15', + stalkerToken: 'REUSED', + stalkerWatchdogTimeout: 45, + stalkerTimeslot: 3, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'REUSED', + reusedStoredToken: true, + }); + + await service.ensureToken({ + _id: 'playlist-15', + portalUrl, + macAddress, + isFullStalkerPortal: true, + // Token present, cadence absent — the shortcut must not fire. + stalkerToken: 'REUSED', + } as Playlist); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + 'playlist-15' + ); + expect(watchdog.applyProfileTiming).toHaveBeenCalledWith( + 'playlist-15', + { watchdogTimeoutSeconds: 45, timeslotSeconds: 3 } + ); + }); + + it('persists a freshly decoded watchdog cadence with the token', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-13' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'NEW-TOKEN', + reusedStoredToken: false, + watchdogTimeoutSeconds: 90, + timeslotSeconds: 12, + }); + + await service.ensureToken({ + _id: 'playlist-13', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-13', + expect.objectContaining({ + stalkerToken: 'NEW-TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + // The identity the session was negotiated for, so a later + // start can refuse to reuse it after an identity edit. + stalkerSessionIdentity: expect.any(String), + }) + ); + }); + + it('rewrites the session when only the cadence changed', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-14', + stalkerToken: 'SAME', + stalkerWatchdogTimeout: 120, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'SAME', + reusedStoredToken: false, + watchdogTimeoutSeconds: 45, + }); + + await service.ensureToken({ + _id: 'playlist-14', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-14', + expect.objectContaining({ stalkerWatchdogTimeout: 45 }) + ); + }); + + it('surfaces the portal failure body when the retry also fails', async () => { + const playlist = { + _id: 'playlist-10', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'ensureToken').mockResolvedValue({ + token: 'token-1', + }); + // The transport marker for a blocked account, on both attempts. + dataService.sendIpcEvent.mockResolvedValue({ + stalkerAuthFailure: 'Access denied.', + }); + + await expect( + service.makeAuthenticatedRequest(playlist, { + type: 'vod', + action: 'get_categories', + }) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'auth-failed', + failureBody: 'Access denied.', + }); + // One retry happened before giving up. + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2); + }); + + it('recognizes the raw PWA plain-text failure body and retries', async () => { + const playlist = { + _id: 'playlist-11', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'ensureToken') + .mockResolvedValueOnce({ token: 'stale' }) + .mockResolvedValueOnce({ token: 'fresh' }); + dataService.sendIpcEvent + .mockResolvedValueOnce('Unauthorized request.') + .mockResolvedValueOnce({ js: { data: [] } }); + + await expect( + service.makeAuthenticatedRequest(playlist, { + type: 'itv', + action: 'get_ordered_list', + }) + ).resolves.toEqual({ js: { data: [] } }); + }); + + it('throws StalkerPortalError with the portal text when auth is refused', async () => { + // Blocked account: get_profile answers status 1 with msg/block_msg. + dataService.sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'token-1', random: 'random-1' }, + }) + .mockResolvedValueOnce({ + js: { + status: 1, + msg: 'device conflict - device_id mismatch', + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + + await expect( + service.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'blocked', + portalText: + 'device conflict - device_id mismatch — Your STB is damaged. Call the provider.', + }); + }); + + it('keeps StalkerPortalError recognizable via instanceof', () => { + expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error); + }); + it('passes an explicit serial into the initial handshake request', async () => { dataService.sendIpcEvent .mockResolvedValueOnce({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 746e95a6c..f31a6c613 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,6 +1,6 @@ import { Injectable, Injector, inject } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; import { + extractStalkerAuthFailureBody, isFullStalkerPortalPlaylist, isFullStalkerPortalUrl, Playlist, @@ -9,10 +9,6 @@ import { } from '@iptvnator/shared/interfaces'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { - isStalkerAuthFailureMessage, - isStalkerAuthFailureResponse, -} from './stalker-portal-discovery.utils'; import { getStalkerPortalIdentityFromPlaylist, LEGACY_DEFAULT_STALKER_SERIAL, @@ -20,75 +16,39 @@ import { stalkerIdentityFingerprint, type StalkerPortalIdentity, } from './stalker-identity.utils'; +import { + StalkerAuthApi, + type StalkerAuthenticateOptions, + type StalkerAuthenticationResult, + type StalkerHandshakeResponse, + type StalkerPortalCredentials, + type StalkerProfileResponse, +} from './stalker-auth.api'; +import { isStalkerAuthorizationFailure } from './stalker-response-classification'; +import { StalkerPortalError } from './stalker-portal-error'; +import { + stalkerSessionFingerprint, + StalkerSessionStore, + type PersistedStalkerSession, +} from './stalker-session-store'; +import { StalkerTokenCache } from './stalker-token-cache'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; -export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity }; +export { + getStalkerPortalIdentityFromPlaylist, + normalizeStalkerPortalIdentity, + stalkerIdentityFingerprint, +}; export type { StalkerPortalIdentity }; - -/** - * SHA1 hash using native Web Crypto API - * Produces correct 40-character hex hash matching real Stalker clients - */ -async function sha1(str: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(str); - const hashBuffer = await crypto.subtle.digest('SHA-1', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Generates SHA1 prehash from MAC address - * This must match what real Stalker clients send - */ -async function generatePrehash(macAddress: string): Promise { - // Use MAC address with colons, uppercase - this is what most clients use - const str = macAddress.toUpperCase(); - return (await sha1(str)).toUpperCase(); -} - -/** - * Generates a random string for metrics - */ -function generateRandom(): string { - const chars = 'abcdef0123456789'; - let result = ''; - for (let i = 0; i < 40; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} +export type { + StalkerAuthenticateOptions, + StalkerAuthenticationResult, + StalkerHandshakeResponse, + StalkerPortalCredentials, + StalkerProfileResponse, +}; export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL; -const STALKER_WATCHDOG_INTERVAL_MS = 25_000; - -export interface StalkerHandshakeResponse { - js: { - token: string; - not_valid?: number; - random?: string; - }; -} - -export interface StalkerProfileResponse { - js: { - id?: string; - name?: string; - mac?: string; - status?: number; - msg?: string; - block_msg?: string; - account_info?: { - login?: string; - expire_date?: number; - tariff_plan_name?: string; - status?: number; - }; - }; -} - -interface StalkerAuthConfirmationResponse { - js?: boolean; -} /** * Service to manage Stalker portal session tokens. @@ -101,40 +61,25 @@ interface StalkerAuthConfirmationResponse { export class StalkerSessionService { private dataService = inject(DataService); // Lazy: PlaylistsService drags the persistence stack and is only needed - // when a watchdog ping re-resolves its playlist from the stored row. + // when a session is resolved from (or written back to) the stored row. private readonly injector = inject(Injector); private readonly logger = createLogger('StalkerSession'); + private readonly authApi = new StalkerAuthApi(this.dataService, this.logger); + private readonly sessionStore = new StalkerSessionStore( + () => this.injector.get(PlaylistsService), + this.logger + ); + private readonly watchdog = new StalkerWatchdogController({ + sendRequest: (playlist, params) => + this.makeAuthenticatedRequest(playlist, params, false), + readPersistedPlaylist: (playlistId) => + this.sessionStore.readRow(playlistId), + logger: this.logger, + }); - // In-memory token cache for the current session, keyed by playlist ID - // and tagged with the identity fingerprint the session was negotiated - // for — an edited MAC/identity must never inherit the previous token. - private tokenCache = new Map< - string, - { token: string; identityFingerprint: string } - >(); - private watchdogPlaylistDecorator: - | ((playlist: Playlist) => Playlist) - | null = null; - - // Pending authentication promises to prevent race conditions. - // When multiple requests need a token simultaneously, they all wait for - // the same auth — but ONLY when they act as the same identity: a result - // negotiated for a pre-edit identity must not be adopted by requests - // carrying the edited one. - private pendingAuth = new Map< - string, - { - promise: Promise<{ token: string; serialNumber?: string }>; - identityFingerprint: string; - } - >(); - private watchdogIntervals = new Map< - string, - ReturnType - >(); - private watchdogPlaylists = new Map(); - private watchdogInFlight = new Set(); - private activeWatchdogPlaylistId: string | null = null; + // Session state for this run, identity-tagged so an edited playlist can + // inherit neither a cached token nor an in-flight authentication. + private readonly tokens = new StalkerTokenCache(); /** * Checks if a URL looks like a full stalker portal URL (requires @@ -149,10 +94,10 @@ export class StalkerSessionService { /** * Gets the cached token for a playlist, or null if not cached. * Identity validation happens in `ensureToken`; this raw accessor stays - * for playback fast paths that cannot supply an identity (PR 6 scope). + * for playback fast paths that cannot supply an identity. */ getCachedToken(playlistId: string): string | null { - return this.tokenCache.get(playlistId)?.token || null; + return this.tokens.get(playlistId); } /** @@ -164,60 +109,38 @@ export class StalkerSessionService { token: string, identitySource: PlaylistMeta ): void { - this.tokenCache.set(playlistId, { + this.tokens.set( + playlistId, token, - identityFingerprint: stalkerIdentityFingerprint(identitySource), - }); - } - - /** - * Lets the repair layer overlay its in-session override on the row a - * watchdog ping resolves: the persisted row can still carry a - * pre-repair configuration while persistence is pending (or failed), - * and pinging that configuration would stop or misdirect the keepalive. - */ - registerWatchdogPlaylistDecorator( - decorator: (playlist: Playlist) => Playlist - ): void { - this.watchdogPlaylistDecorator = decorator; + stalkerSessionFingerprint(identitySource) + ); } /** * Clears the cached token for a playlist (e.g., on auth failure) */ clearCachedToken(playlistId: string): void { - this.tokenCache.delete(playlistId); + this.tokens.clear(playlistId); + } + + /** + * Lets the repair layer overlay its in-session override on the row a + * watchdog ping resolves. + */ + registerWatchdogPlaylistDecorator( + decorator: (playlist: Playlist) => Playlist + ): void { + this.watchdog.registerPlaylistDecorator(decorator); } /** * Sets which playlist should receive periodic watchdog pings. * This keeps some Ministra/Stalker sessions alive for live playback. + * The cadence comes from the portal's profile (`watchdog_timeout` + + * `timeslot`), defaulting to the documented 120 s. */ setActiveWatchdogPlaylist(playlist?: Playlist | null): void { - const nextPlaylistId = playlist?._id ?? null; - - if ( - this.activeWatchdogPlaylistId && - this.activeWatchdogPlaylistId !== nextPlaylistId - ) { - this.stopWatchdog(this.activeWatchdogPlaylistId); - } - - this.activeWatchdogPlaylistId = nextPlaylistId; - - if ( - !playlist || - !isFullStalkerPortalPlaylist(playlist) || - !playlist.portalUrl || - !playlist.macAddress - ) { - if (nextPlaylistId) { - this.stopWatchdog(nextPlaylistId); - } - return; - } - - this.startWatchdog(playlist); + this.watchdog.setActivePlaylist(playlist); } /** @@ -225,357 +148,146 @@ export class StalkerSessionService { * was just repaired. Only reacts when the playlist IS the active * watchdog target: a simple→full repair starts the required keepalive, * full→simple stops it, and an endpoint change repoints the pings — - * without waiting for the next route activation (the activation-time - * snapshot in `watchdogPlaylists` would otherwise stay stale). + * without waiting for the next route activation. + * + * Delegation is the contract: `setActiveWatchdogPlaylist` owns the + * start/stop/repoint logic, this only feeds it the fresh row. */ refreshActiveWatchdogPlaylist(playlist: Playlist): void { - if (this.activeWatchdogPlaylistId !== playlist._id) { + if (!this.watchdog.isActivePlaylist(playlist._id)) { return; } this.setActiveWatchdogPlaylist(playlist); } - private startWatchdog(playlist: Playlist): void { - const playlistId = playlist._id; - this.watchdogPlaylists.set(playlistId, playlist); - - if (this.watchdogIntervals.has(playlistId)) { - return; - } - - void this.sendWatchdogPing(playlistId, '1'); - - const intervalId = setInterval(() => { - void this.sendWatchdogPing(playlistId, '0'); - }, STALKER_WATCHDOG_INTERVAL_MS); - - this.watchdogIntervals.set(playlistId, intervalId); - } - - private stopWatchdog(playlistId: string): void { - const intervalId = this.watchdogIntervals.get(playlistId); - if (intervalId) { - clearInterval(intervalId); - this.watchdogIntervals.delete(playlistId); - } - this.watchdogPlaylists.delete(playlistId); - this.watchdogInFlight.delete(playlistId); - } - /** - * The playlist a watchdog ping authenticates as. The persisted row is - * the source of truth: portal metadata (endpoint, mode, MAC, identity) - * edited or repaired mid-session must reach the keepalive within one - * ping cycle — the activation-time snapshot is only the fallback when - * the row cannot be read. + * Adopts a session another layer already negotiated — today the endpoint + * discovery run behind a lazy repair, whose classification handshake and + * `get_profile` produced both a token and the portal's cadence. + * + * Caching the token alone (as the repair used to) leaves the retry + * satisfied and no authentication path ever applies the profile outcome, + * so a freshly repaired playlist would keep pinging on the default + * cadence until the token failed or the app restarted. + * + * `identitySource` must describe the REPAIRED configuration: the session + * belongs to the endpoint it was negotiated against. */ - private async resolveWatchdogPlaylist( - playlistId: string - ): Promise { - try { - const row = await firstValueFrom( - this.injector - .get(PlaylistsService) - .getPlaylistById(playlistId) - ); - if (row) { - const playlist = row as Playlist; - // Keep the fallback snapshot fresh for the next cycle. - this.watchdogPlaylists.set(playlistId, playlist); - return this.decorateWatchdogPlaylist(playlist); - } - } catch { - // Store unavailable (e.g. isolated tests): keep the snapshot. - } - - const snapshot = this.watchdogPlaylists.get(playlistId); - return snapshot ? this.decorateWatchdogPlaylist(snapshot) : snapshot; - } - - /** - * Overlays the repair layer's in-session override (when registered) so - * a ping never authenticates against a configuration a completed repair - * has already proven broken — even before persistence lands. - */ - private decorateWatchdogPlaylist(playlist: Playlist): Playlist { - return this.watchdogPlaylistDecorator - ? this.watchdogPlaylistDecorator(playlist) - : playlist; - } - - private async sendWatchdogPing( + adoptDiscoveredSession( playlistId: string, - init: '0' | '1' - ): Promise { - if (this.watchdogInFlight.has(playlistId)) { - return; - } - - // Claimed BEFORE the row read: it awaits, and two overlapping pings - // passing the check together would double-fire the keepalive. - this.watchdogInFlight.add(playlistId); - try { - const playlist = await this.resolveWatchdogPlaylist(playlistId); - if ( - !playlist || - !playlist.portalUrl || - !playlist.macAddress || - !isFullStalkerPortalPlaylist(playlist) - ) { - this.stopWatchdog(playlistId); - return; - } - - await this.makeAuthenticatedRequest( - playlist, - { - type: 'watchdog', - action: 'get_events', - event_active_id: '0', - cur_play_type: '0', - init, - JsHttpRequest: '1-xml', - }, - false - ); - } catch (error) { - // Keep failures non-fatal; next interval can recover after token refresh. - this.logger.warn('Watchdog ping failed:', error); - } finally { - this.watchdogInFlight.delete(playlistId); + identitySource: Playlist, + session: { + token: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; } + ): void { + this.setCachedToken(playlistId, session.token, identitySource); + this.applySessionOutcome( + playlistId, + { + token: session.token, + reusedStoredToken: false, + watchdogTimeoutSeconds: session.watchdogTimeoutSeconds, + timeslotSeconds: session.timeslotSeconds, + }, + { + token: identitySource.stalkerToken, + identityFingerprint: identitySource.stalkerSessionIdentity, + watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout, + timeslotSeconds: identitySource.stalkerTimeslot, + }, + stalkerSessionFingerprint(identitySource) + ); } /** - * Performs handshake to get a session token for a full stalker portal - * Returns both the token and the random value for use in subsequent requests + * Performs handshake to get a session token for a full stalker portal. + * An optional persisted token is re-presented: the handshake is + * idempotent, so a still-valid token comes back unchanged. */ - async performHandshake( + performHandshake( portalUrl: string, macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ token: string; random: string }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - const prehash = await generatePrehash(macAddress); - - const params: Record = { - type: 'stb', - action: 'handshake', - token: '', - prehash, - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerHandshakeResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - if (response?.js?.token) { - return { - token: response.js.token, - random: response.js.random || generateRandom(), - }; - } - - this.logger.error('No token in response'); - throw new Error('Handshake failed: No token received'); - } catch (error) { - this.logger.error('Handshake error:', error); - throw error; - } + identity: StalkerPortalIdentity = {}, + storedToken?: string + ): Promise<{ token: string; random: string; notValid: boolean }> { + return this.authApi.performHandshake( + portalUrl, + macAddress, + identity, + storedToken + ); } /** - * Gets account profile information to validate the portal and check subscription - * Based on working implementation from stalker-to-m3u repo + * Gets account profile information to validate the portal and check + * subscription. */ - async getProfile( + getProfile( portalUrl: string, macAddress: string, token: string, identity: StalkerPortalIdentity, - handshakeRandom: string + handshakeRandom: string, + options: { authSecondStep?: boolean; notValidToken?: boolean } = {} ): Promise { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Build metrics JSON matching working app - const metrics: Record = { - mac: macAddress, - model: 'MAG250', - type: 'STB', - random: handshakeRandom, - ...(normalizedIdentity.serialNumber - ? { sn: normalizedIdentity.serialNumber } - : {}), - }; - - // Generate prehash for get_profile (same as handshake) - const prehash = await generatePrehash(macAddress); - - // Profile request matching working StalkerTV app - // auth_second_step=1, includes metrics, prehash, and device_id params - const params: Record = { - type: 'stb', - action: 'get_profile', - hd: '1', - not_valid_token: '0', - video_out: 'hdmi', - auth_second_step: '1', - num_banks: '2', - metrics: JSON.stringify(metrics), - ...(normalizedIdentity.serialNumber - ? { sn: normalizedIdentity.serialNumber } - : {}), - ...(normalizedIdentity.deviceId1 - ? { device_id: normalizedIdentity.deviceId1 } - : {}), - ...(normalizedIdentity.deviceId2 - ? { device_id2: normalizedIdentity.deviceId2 } - : {}), - ...(normalizedIdentity.signature1 - ? { signature: normalizedIdentity.signature1 } - : {}), - ...(normalizedIdentity.signature2 - ? { signature2: normalizedIdentity.signature2 } - : {}), - prehash: prehash, - stb_type: '', - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerProfileResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - return response; - } catch (error) { - this.logger.error('Get profile error:', error); - throw error; - } - } - - /** - * Performs do_auth to authenticate the session after handshake - */ - async doAuth( - portalUrl: string, - macAddress: string, - token: string - ): Promise { - const params: Record = { - type: 'stb', - action: 'do_auth', - login: '', - password: '', - JsHttpRequest: '1-xml', - }; - - try { - const response = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - } - ); - - // do_auth returns { js: true } on success - if (response?.js === true) { - return true; - } - - return false; - } catch (error) { - this.logger.error('do_auth error:', error); - throw error; - } - } - - /** - * Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces) - * Returns the token and account info if successful - */ - async authenticate( - portalUrl: string, - macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ - token: string; - accountInfo?: StalkerProfileResponse['js']['account_info']; - /** Raw envelope so callers can apply their own failure checks. */ - profileResponse?: StalkerProfileResponse; - }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Step 1: Handshake to get token and random - const { token, random } = await this.performHandshake( + return this.authApi.getProfile( portalUrl, macAddress, - normalizedIdentity + token, + identity, + handshakeRandom, + options ); - - // Step 2: Get profile to activate token and get account info - // The random from handshake must be used in auth_second_step - try { - const profileResponse = await this.getProfile( - portalUrl, - macAddress, - token, - normalizedIdentity, - random - ); - - // Check for profile-level errors - if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) { - const errorMsg = - profileResponse.js.msg || - profileResponse.js.block_msg || - 'Unknown profile error'; - this.logger.error('Profile error:', errorMsg); - throw new Error(`Profile error: ${errorMsg}`); - } - - return { - token, - accountInfo: profileResponse?.js?.account_info, - profileResponse, - }; - } catch (error) { - // Profile fetch failed - this is a real error, propagate it - this.logger.error('Profile fetch failed:', error); - throw error; - } } /** - * Ensures a valid token exists for a playlist, performing full auth if needed - * IMPORTANT: Based on working traces, each session needs handshake + get_profile - * Returns the token to use for requests, and the serial number to store + * Performs do_auth — the login/password step behind get_profile status 2. + */ + doAuth( + portalUrl: string, + macAddress: string, + token: string, + credentials: StalkerPortalCredentials, + identity: StalkerPortalIdentity = {} + ): Promise { + return this.authApi.doAuth( + portalUrl, + macAddress, + token, + credentials, + identity + ); + } + + /** + * Performs the full authentication flow: handshake → get_profile, driving + * the status-2 `do_auth` login flow when the portal demands credentials. + * Throws `StalkerPortalError` with the portal's own `msg`/`block_msg` + * text when the portal refuses the session. + */ + authenticate( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + options: StalkerAuthenticateOptions = {} + ): Promise { + return this.authApi.authenticate( + portalUrl, + macAddress, + identity, + options + ); + } + + /** + * Ensures a valid token exists for a playlist, performing auth if needed. + * A previously persisted token is re-presented in the handshake first — + * while it is still the MAC's session token the portal returns it + * unchanged and the `get_profile` round trip is skipped entirely. + * Returns the token to use for requests, and the serial number to store. */ async ensureToken( playlist: Playlist @@ -586,33 +298,22 @@ export class StalkerSessionService { } const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); - // Check in-memory cache first (valid for current session only). - const cached = this.tokenCache.get(playlist._id); - if (cached) { - if ( - cached.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { - return { - token: cached.token, - serialNumber: identity.serialNumber, - }; - } - // The cached session was negotiated for a DIFFERENT identity - // (the playlist was edited): retire it and authenticate as the - // current one instead of pairing new identity with old session. - this.clearCachedToken(playlist._id); + // Only the session negotiated for THIS identity may be reused. + const cachedToken = this.tokens.takeFor(playlist._id, fingerprint); + if (cachedToken) { + return { token: cachedToken, serialNumber: identity.serialNumber }; } // Check if there's already a pending authentication for this playlist // This prevents race conditions when multiple resources request a token simultaneously - const pendingEntry = this.pendingAuth.get(playlist._id); + const pendingEntry = this.tokens.getPending(playlist._id); if (pendingEntry) { - if ( - pendingEntry.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { + if (pendingEntry.identityFingerprint === fingerprint) { this.logger.debug('Waiting for pending authentication...'); return pendingEntry.promise; } @@ -629,8 +330,6 @@ export class StalkerSessionService { return this.ensureToken(playlist); } - // No cached token - need to do full authentication (handshake + get_profile) - // Don't trust stored tokens as they may be from a different session if (!playlist.portalUrl || !playlist.macAddress) { this.logger.error('Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); @@ -642,23 +341,54 @@ export class StalkerSessionService { // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { try { - const { token } = await this.authenticate( + // The PERSISTED session is bound to the endpoint too: a + // playlist repointed at another host must not re-present the + // previous portal's token to it. + const stored = await this.sessionStore.read( + playlist, + fingerprint + ); + const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + storedToken: stored.token, + credentials: { + username: playlist.username, + password: playlist.password, + }, + // Only skip the profile once the cadence is known. A + // playlist stored before the cadence was persisted + // has a reusable token and no cadence, and skipping + // would strand it on the 120 s default forever — the + // profile is the only thing that could teach it. One + // request, once; every later start skips. + skipProfileWhenReused: + stored.watchdogTimeoutSeconds !== undefined, + } ); - this.setCachedToken(playlist._id, token, playlist); - return { token, serialNumber: identity.serialNumber }; + this.setCachedToken(playlist._id, result.token, playlist); + this.applySessionOutcome( + playlist._id, + result, + stored, + fingerprint + ); + return { + token: result.token, + serialNumber: identity.serialNumber, + }; } finally { // Clean up pending promise regardless of success/failure - this.pendingAuth.delete(playlist._id); + this.tokens.clearPending(playlist._id); } })(); // Store the pending promise so other concurrent requests can wait on it - this.pendingAuth.set(playlist._id, { + this.tokens.setPending(playlist._id, { promise: authPromise, - identityFingerprint: stalkerIdentityFingerprint(playlist), + identityFingerprint: fingerprint, }); return authPromise; @@ -685,14 +415,18 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); // Claim the per-playlist slot. Re-check after every await: one // settled promise releases every waiter at once, so a single // pre-check would let them all start competing handshakes. for ( - let inFlight = this.pendingAuth.get(playlist._id); + let inFlight = this.tokens.getPending(playlist._id); inFlight; - inFlight = this.pendingAuth.get(playlist._id) + inFlight = this.tokens.getPending(playlist._id) ) { this.logger.debug('Waiting for pending authentication...'); // A failed pending auth must not abort the refresh; this call @@ -718,11 +452,8 @@ export class StalkerSessionService { // Waiters attach their own handlers; this one only keeps a // rejected slot from surfacing as an unhandled rejection. void slot.catch(() => undefined); - const slotEntry = { - promise: slot, - identityFingerprint: stalkerIdentityFingerprint(playlist), - }; - this.pendingAuth.set(playlist._id, slotEntry); + const slotEntry = { promise: slot, identityFingerprint: fingerprint }; + this.tokens.setPending(playlist._id, slotEntry); // ensureToken() reads tokenCache before pendingAuth, so leaving the // old token there would hand a token this handshake is about to @@ -734,9 +465,29 @@ export class StalkerSessionService { const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); this.setCachedToken(playlist._id, result.token, playlist); + // This path always ran a real get_profile, so the decoded cadence + // is authoritative; the previous values are only the write-back + // comparison baseline. + this.applySessionOutcome( + playlist._id, + result, + { + token: playlist.stalkerToken, + identityFingerprint: playlist.stalkerSessionIdentity, + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }, + fingerprint + ); settleSlot({ token: result.token, serialNumber: identity.serialNumber, @@ -748,70 +499,24 @@ export class StalkerSessionService { } finally { // Only retire our own entry: a caller that started a later // authentication owns the map slot from then on. - if (this.pendingAuth.get(playlist._id) === slotEntry) { - this.pendingAuth.delete(playlist._id); - } + this.tokens.clearPendingIf(playlist._id, slotEntry); } } - /** - * Clears the cached token only while it is still the one that just - * failed. A request dispatched with the previous token can see its - * authorization failure arrive after a profile refresh has already - * cached a fresh token — deleting blindly would kill that fresh token - * and trigger a cascade of competing handshakes on strict portals. - */ - private retireFailedToken( + /** Delegates the watchdog + write-back half of an authentication. */ + private applySessionOutcome( playlistId: string, - failedToken: string | null + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string ): void { - if (failedToken && this.getCachedToken(playlistId) === failedToken) { - this.clearCachedToken(playlistId); - } - } - - /** - * Checks if a response or error indicates an authorization failure - */ - private isAuthorizationError(responseOrError: unknown): boolean { - if (!responseOrError) return false; - - const response = responseOrError as Record; - - // The complete set of portal auth failures — the plain-text bodies - // (`Authorization failed.`, `Access denied.`, `Unauthorized - // request.`) and their JSON-envelope forms. Shared with endpoint - // discovery and the lazy repair so a phrase one layer classifies as - // an auth failure cannot be ignored by another: the session would - // otherwise keep an expired token and every later request fails. - if ( - isStalkerAuthFailureResponse(responseOrError) || - isStalkerAuthFailureMessage(response?.['message']) - ) { - return true; - } - - // HTTP auth codes surviving the IPC boundary only as message text - // (`HTTP Error 401: …`): the custom `status` property is stripped by - // ipcRenderer, so the numeric code must be read from the message. - if ( - typeof response?.['message'] === 'string' && - /HTTP Error 40[13]\b/.test(response['message']) - ) { - return true; - } - - // Everything above is structural. What used to follow was a - // `JSON.stringify(responseOrError)` sweep with an UNANCHORED - // `authorization failed` pattern — the same false positive the body - // detector was just anchored against, reachable through a different - // door: a short proxy/WAF page containing the phrase retired the - // token, retried, and threw `Authorization failed after retry`, - // whose own message then matched the repair trigger and re-probed a - // portal that never refused anything. The shared detector already - // covers every real shape, including the `js.error`/`js.msg` - // envelopes, so the sweep only added the false positive. - return response?.['status'] === 401 || response?.['status'] === 403; + this.sessionStore.applyAuthenticationOutcome( + playlistId, + result, + stored, + fingerprint, + this.watchdog + ); } /** @@ -839,12 +544,15 @@ export class StalkerSessionService { ); // Check for authorization failure in response - if (this.isAuthorizationError(response)) { + if (isStalkerAuthorizationFailure(response)) { // Retire the failed token even when not retrying (e.g. // watchdog pings): leaving it cached would hand a dead // session to the next caller. - this.retireFailedToken(playlist._id, token); - if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) { + this.tokens.retireFailed(playlist._id, token); + if ( + retryOnAuthFailure && + isFullStalkerPortalPlaylist(playlist) + ) { // Retry once with fresh authentication return this.makeAuthenticatedRequest( playlist, @@ -853,16 +561,22 @@ export class StalkerSessionService { ); } - throw new Error('Authorization failed after retry'); + const failureBody = + extractStalkerAuthFailureBody(response) ?? undefined; + throw new StalkerPortalError( + 'auth-failed', + failureBody, + failureBody + ); } return response; } catch (error) { // Check if error indicates auth failure - if (this.isAuthorizationError(error)) { + if (isStalkerAuthorizationFailure(error)) { // Same rule as above: a failed session is retired even on // the no-retry path. - this.retireFailedToken(playlist._id, token); + this.tokens.retireFailed(playlist._id, token); if ( retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts new file mode 100644 index 000000000..64015da2e --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts @@ -0,0 +1,91 @@ +export interface StalkerPendingAuth { + promise: Promise<{ token: string; serialNumber?: string }>; + identityFingerprint: string; +} + +/** + * In-memory session state for the current app run, keyed by playlist ID and + * tagged with the identity fingerprint the session was negotiated for. + * + * The tagging is the point: a playlist whose endpoint, identity or login was + * edited must never inherit the previous session — neither the cached token + * nor an authentication still in flight for the old one. The key comes from + * `stalkerSessionFingerprint`, so an edit applies without a restart. + */ +export class StalkerTokenCache { + private readonly tokens = new Map< + string, + { token: string; identityFingerprint: string } + >(); + private readonly pending = new Map(); + + /** + * The cached token regardless of identity. Playback fast paths that + * cannot supply an identity use this; `takeFor` is the checked accessor. + */ + get(playlistId: string): string | null { + return this.tokens.get(playlistId)?.token || null; + } + + set(playlistId: string, token: string, sessionFingerprint: string): void { + this.tokens.set(playlistId, { + token, + identityFingerprint: sessionFingerprint, + }); + } + + clear(playlistId: string): void { + this.tokens.delete(playlistId); + } + + /** + * The cached token when it was negotiated for this exact identity. A + * mismatch retires the entry and returns null, so the caller + * authenticates as the current identity instead of pairing a new one + * with an old session. + */ + takeFor(playlistId: string, fingerprint: string): string | null { + const cached = this.tokens.get(playlistId); + if (!cached) { + return null; + } + if (cached.identityFingerprint === fingerprint) { + return cached.token; + } + + this.clear(playlistId); + return null; + } + + /** + * Clears the cached token only while it is still the one that just + * failed. A request dispatched with the previous token can see its + * authorization failure arrive after a profile refresh has already + * cached a fresh token — deleting blindly would kill that fresh token + * and trigger a cascade of competing handshakes on strict portals. + */ + retireFailed(playlistId: string, failedToken: string | null): void { + if (failedToken && this.get(playlistId) === failedToken) { + this.clear(playlistId); + } + } + + getPending(playlistId: string): StalkerPendingAuth | undefined { + return this.pending.get(playlistId); + } + + setPending(playlistId: string, entry: StalkerPendingAuth): void { + this.pending.set(playlistId, entry); + } + + clearPending(playlistId: string): void { + this.pending.delete(playlistId); + } + + /** Retires the pending slot only when it is still the caller's own. */ + clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void { + if (this.pending.get(playlistId) === entry) { + this.pending.delete(playlistId); + } + } +} 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/stalker-watchdog.controller.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts new file mode 100644 index 000000000..760dc488f --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts @@ -0,0 +1,168 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; + +describe('StalkerWatchdogController', () => { + const playlist = { + _id: 'playlist-1', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + } as Playlist; + + let sendRequest: jest.Mock; + let readPersistedPlaylist: jest.Mock; + let controller: StalkerWatchdogController; + + const logger = { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + sendRequest = jest.fn().mockResolvedValue({ js: {} }); + // The row is the source of truth for each ping; specs that care + // about mid-session edits override this per test. + readPersistedPlaylist = jest.fn().mockResolvedValue(playlist); + controller = new StalkerWatchdogController({ + sendRequest, + readPersistedPlaylist, + logger, + }); + }); + + afterEach(() => { + controller.setActivePlaylist(null); + jest.useRealTimers(); + }); + + /** Lets the async sendPing settle between timer advances. */ + async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + } + + it('pings immediately with init=1, then every 120 s by default', async () => { + controller.setActivePlaylist(playlist); + await flush(); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith( + playlist, + expect.objectContaining({ + action: 'get_events', + type: 'watchdog', + init: '1', + }) + ); + + // The legacy cadence was 25 s — nothing may fire that early. + jest.advanceTimersByTime(25_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(95_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(sendRequest.mock.calls[1][1]).toEqual( + expect.objectContaining({ init: '0' }) + ); + }); + + it('reschedules to the profile cadence with the timeslot offset', async () => { + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 90, + timeslotSeconds: 10, + }); + + // Timeslot offset: nothing during the first 10 s... + jest.advanceTimersByTime(9_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + + // ...then the 90 s cadence starts. + jest.advanceTimersByTime(91_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(90_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + }); + + it('keeps the stored cadence when the watchdog restarts', async () => { + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 60, + timeslotSeconds: 0, + }); + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + jest.advanceTimersByTime(60_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('clamps a garbage watchdog_timeout instead of adopting it', async () => { + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 1, + timeslotSeconds: 0, + }); + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + // Clamped to the 30 s floor — a 1 s cadence would hammer the portal. + jest.advanceTimersByTime(29_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('logs and continues when a ping fails — never retries or escalates', async () => { + sendRequest.mockRejectedValue(new Error('portal down')); + controller.setActivePlaylist(playlist); + await flush(); + + expect(logger.warn).toHaveBeenCalled(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + // No immediate retry: the next attempt is the next scheduled tick. + jest.advanceTimersByTime(119_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + }); + + it('stops pinging when the active playlist is cleared', async () => { + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + controller.setActivePlaylist(null); + jest.advanceTimersByTime(600_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('never pings for a simple (non-full) portal', async () => { + controller.setActivePlaylist({ + ...playlist, + isFullStalkerPortal: false, + } as Playlist); + jest.advanceTimersByTime(600_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts new file mode 100644 index 000000000..3e94a8708 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -0,0 +1,294 @@ +import { + isFullStalkerPortalPlaylist, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; + +/** + * The portal expects `get_events` every `watchdog_timeout` seconds — 120 by + * default — not every 25 s as the client historically pinged. + */ +export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120; + +/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */ +const MIN_PERIOD_SECONDS = 30; +const MAX_PERIOD_SECONDS = 3600; + +export interface StalkerWatchdogTiming { + periodSeconds: number; + /** Per-user jitter: delays the first periodic ping so clients spread out. */ + timeslotSeconds: number; +} + +export interface StalkerWatchdogDeps { + /** Sends one authenticated `get_events` ping. */ + sendRequest: ( + playlist: Playlist, + params: Record + ) => Promise; + /** + * Reads the persisted row for a playlist. The row is the source of truth + * for the configuration a ping authenticates as; see `resolvePlaylist`. + */ + readPersistedPlaylist: (playlistId: string) => Promise; + logger: ReturnType; +} + +interface WatchdogTimers { + timeslotTimeout?: ReturnType; + interval?: ReturnType; +} + +/** + * Keeps the active full portal's session "online" by pinging `get_events`. + * + * The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until + * a profile has been decoded the documented default of 120 s applies. A missed + * ping never invalidates authentication — it only affects the portal's + * "online" reporting — so failures are logged and never retried or escalated. + */ +export class StalkerWatchdogController { + private readonly timers = new Map(); + /** Activation-time snapshot; only a fallback when the row cannot be read. */ + private readonly playlists = new Map(); + private readonly inFlight = new Set(); + /** Survives stop/start: the cadence is a per-portal fact, not session state. */ + private readonly timings = new Map(); + private activePlaylistId: string | null = null; + private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null; + + constructor(private readonly deps: StalkerWatchdogDeps) {} + + /** + * Sets which playlist should receive periodic watchdog pings. + * This keeps some Ministra/Stalker sessions alive for live playback. + */ + setActivePlaylist(playlist?: Playlist | null): void { + const nextPlaylistId = playlist?._id ?? null; + + if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) { + this.stop(this.activePlaylistId); + } + + this.activePlaylistId = nextPlaylistId; + + if ( + !playlist || + !isFullStalkerPortalPlaylist(playlist) || + !playlist.portalUrl || + !playlist.macAddress + ) { + if (nextPlaylistId) { + this.stop(nextPlaylistId); + } + return; + } + + this.start(playlist); + } + + /** Whether this playlist currently owns the watchdog. */ + isActivePlaylist(playlistId: string): boolean { + return this.activePlaylistId === playlistId; + } + + /** + * Lets the repair layer overlay its in-session override on the row a ping + * resolves: the persisted row can still carry a pre-repair configuration + * while persistence is pending (or failed), and pinging that would stop + * or misdirect the keepalive. + */ + registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void { + this.playlistDecorator = decorator; + } + + /** + * Applies the cadence decoded from a `get_profile` response. When the + * playlist's watchdog is already running on a different cadence, its + * periodic schedule restarts (without re-sending the init ping). + */ + applyProfileTiming( + playlistId: string, + timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number } + ): void { + const next = normalizeTiming(timing); + const current = this.timings.get(playlistId); + if ( + current && + current.periodSeconds === next.periodSeconds && + current.timeslotSeconds === next.timeslotSeconds + ) { + return; + } + + this.timings.set(playlistId, next); + + if (this.timers.has(playlistId)) { + this.clearTimers(playlistId); + this.timers.set(playlistId, {}); + this.schedule(playlistId); + } + } + + private start(playlist: Playlist): void { + const playlistId = playlist._id; + this.playlists.set(playlistId, playlist); + + if (this.timers.has(playlistId)) { + return; + } + + this.timers.set(playlistId, {}); + void this.sendPing(playlistId, '1'); + this.schedule(playlistId); + } + + private stop(playlistId: string): void { + this.clearTimers(playlistId); + this.timers.delete(playlistId); + this.playlists.delete(playlistId); + this.inFlight.delete(playlistId); + } + + private clearTimers(playlistId: string): void { + const timers = this.timers.get(playlistId); + if (!timers) { + return; + } + if (timers.timeslotTimeout) { + clearTimeout(timers.timeslotTimeout); + } + if (timers.interval) { + clearInterval(timers.interval); + } + } + + private timingFor(playlistId: string): StalkerWatchdogTiming { + return ( + this.timings.get(playlistId) ?? { + periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + timeslotSeconds: 0, + } + ); + } + + private schedule(playlistId: string): void { + const timers = this.timers.get(playlistId); + if (!timers) { + return; + } + + const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId); + const startInterval = () => { + const current = this.timers.get(playlistId); + if (!current) { + return; + } + current.timeslotTimeout = undefined; + current.interval = setInterval(() => { + void this.sendPing(playlistId, '0'); + }, periodSeconds * 1000); + }; + + if (timeslotSeconds > 0) { + timers.timeslotTimeout = setTimeout( + startInterval, + timeslotSeconds * 1000 + ); + } else { + startInterval(); + } + } + + /** + * The playlist a ping authenticates as. The persisted row is the source of + * truth: portal metadata (endpoint, mode, MAC, identity) edited or + * repaired mid-session must reach the keepalive within one cycle — the + * activation-time snapshot is only the fallback when the row cannot be + * read. + */ + private async resolvePlaylist( + playlistId: string + ): Promise { + try { + const row = await this.deps.readPersistedPlaylist(playlistId); + if (row) { + // Keep the fallback snapshot fresh for the next cycle. + this.playlists.set(playlistId, row); + return this.decorate(row); + } + } catch { + // Store unavailable (e.g. isolated tests): keep the snapshot. + } + + const snapshot = this.playlists.get(playlistId); + return snapshot ? this.decorate(snapshot) : snapshot; + } + + private decorate(playlist: Playlist): Playlist { + return this.playlistDecorator + ? this.playlistDecorator(playlist) + : playlist; + } + + private async sendPing(playlistId: string, init: '0' | '1'): Promise { + if (this.inFlight.has(playlistId)) { + return; + } + + // Claimed BEFORE the row read: it awaits, and two overlapping pings + // passing the check together would double-fire the keepalive. + this.inFlight.add(playlistId); + try { + const playlist = await this.resolvePlaylist(playlistId); + if ( + !playlist || + !playlist.portalUrl || + !playlist.macAddress || + !isFullStalkerPortalPlaylist(playlist) + ) { + this.stop(playlistId); + return; + } + + await this.deps.sendRequest(playlist, { + type: 'watchdog', + action: 'get_events', + event_active_id: '0', + cur_play_type: '0', + init, + JsHttpRequest: '1-xml', + }); + } catch (error) { + // Keep failures non-fatal; the next tick can recover after a + // token refresh. Never retry or escalate: a missed ping does not + // invalidate authentication. + this.deps.logger.warn('Watchdog ping failed:', error); + } finally { + this.inFlight.delete(playlistId); + } + } +} + +function normalizeTiming(timing: { + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +}): StalkerWatchdogTiming { + const period = clamp( + Math.floor( + timing.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS + ), + MIN_PERIOD_SECONDS, + MAX_PERIOD_SECONDS + ); + const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1); + return { periodSeconds: period, timeslotSeconds: timeslot }; +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(value, min), max); +} 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/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 144d062a0..dbf621fa1 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -441,6 +441,115 @@ describe('PlaylistsService', () => { ).toBe('1'); }); + it('carries the Stalker session fields through the SQLite fallback read', async () => { + // Without these, every cold Electron session loses the persisted + // cadence AND the identity the token was negotiated for — so the + // mismatch check cannot run and the watchdog falls back to default. + const electron = { + dbGetAppPlaylist: jest.fn(async () => ({ + id: 'stalker-1', + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + })), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async () => '1'), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + const playlist = await firstValueFrom( + service.getPlaylistById('stalker-1') + ); + + expect(playlist).toEqual( + expect.objectContaining({ + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + }) + ); + }); + + it('clears a stale watchdog cadence when the portal stops advertising one', async () => { + // Reusing the token skips the profile that would correct the cadence, + // so leaving the old value on the row means the next restart applies + // a cadence the portal no longer asks for. + const existingPlaylist: Playlist = { + _id: 'stalker-1', + title: 'Stalker', + count: 0, + importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), + autoRefresh: false, + stalkerToken: 'OLD', + stalkerWatchdogTimeout: 45, + stalkerTimeslot: 7, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' }) + ); + + const written = dbService.update.mock.calls[0][1] as Playlist; + expect(written.stalkerToken).toBe('NEW'); + expect(written.stalkerWatchdogTimeout).toBeUndefined(); + expect(written.stalkerTimeslot).toBeUndefined(); + }); + + it('persists the watchdog cadence alongside the stalker token', async () => { + const existingPlaylist: Playlist = { + _id: 'stalker-1', + title: 'Stalker', + count: 0, + importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), + autoRefresh: false, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updateStalkerSession('stalker-1', { + stalkerToken: 'TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + }) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + stalkerToken: 'TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + }) + ); + }); + it('persists hiddenGroupTitles in playlist meta updates', async () => { const existingPlaylist: Playlist = { _id: 'playlist-1', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2b3031066..942f980c8 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -242,6 +242,13 @@ export class PlaylistsService { stalkerSignature2: playlist.stalkerSignature2, isFullStalkerPortal: playlist.isFullStalkerPortal, stalkerToken: playlist.stalkerToken, + // Without these the Electron cold read loses the persisted + // cadence AND the identity the token was negotiated for, so the + // mismatch check cannot run and the watchdog falls back to the + // default. + stalkerSessionIdentity: playlist.stalkerSessionIdentity, + stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, + stalkerTimeslot: playlist.stalkerTimeslot, stalkerAccountInfo: playlist.stalkerAccountInfo, } as Playlist; } @@ -729,6 +736,45 @@ export class PlaylistsService { }); } + /** + * Persists a freshly negotiated Stalker session on the playlist so the + * next app start can re-present the token (the portal handshake is + * idempotent) and keep the portal's own watchdog cadence — reusing a + * token skips the `get_profile` that carries the cadence, so it has to + * survive with the token. No-ops when the playlist row does not exist + * yet: the import flow saves both with the playlist itself. + */ + updateStalkerSession( + playlistId: string, + session: { + stalkerToken: string; + stalkerSessionIdentity?: string; + stalkerWatchdogTimeout?: number; + stalkerTimeslot?: number; + } + ) { + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + if (!playlist) { + return null; + } + + // The cadence is written unconditionally, including as + // `undefined`: a portal that stops advertising one must not leave + // a stale value behind for the next restart to re-apply, since + // reusing the token skips the profile that would correct it. + return this.persistPlaylistMutation({ + ...playlist, + stalkerToken: session.stalkerToken, + stalkerSessionIdentity: session.stalkerSessionIdentity, + stalkerWatchdogTimeout: session.stalkerWatchdogTimeout, + stalkerTimeslot: session.stalkerTimeslot, + }); + }); + } + updateFavorites(id: string, favorites: string[]) { return this.serializePlaylistWrite(id, async () => { const playlist = await firstValueFrom(this.getPlaylistById(id)); diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 2721ce84e..f16e5a709 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -58,6 +58,23 @@ export interface Playlist { serverTimezone?: string; /** Session token for full stalker portal authentication - persisted for session */ stalkerToken?: string; + /** + * Identity fingerprint the persisted `stalkerToken` was negotiated for. + * Re-presenting a token minted for a DIFFERENT identity (the user edited + * the MAC, serial or device ids) would pair a new identity with an old + * session — the same class of bug the in-memory cache guards against, so + * reuse is refused unless this still matches the playlist. + */ + stalkerSessionIdentity?: string; + /** + * Watchdog cadence the portal advertised in `get_profile`, persisted + * alongside the token: reusing a stored token skips the profile request + * that carries these, so without persistence the keep-alive would fall + * back to the 120 s default forever. + */ + stalkerWatchdogTimeout?: number; + /** Per-user watchdog jitter (seconds) from `get_profile`. */ + stalkerTimeslot?: number; /** Serial number for stalker portal - generated once and stored for consistency */ stalkerSerialNumber?: string; /** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */ diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts index 4332d46e7..fd6940178 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts @@ -1,5 +1,7 @@ import { classifyStalkerAuthFailureBody, + createStalkerAuthFailureMarker, + extractStalkerAuthFailureBody, isStalkerAuthFailureBody, isStalkerAuthFailureMessage, isStalkerAuthFailureResponse, @@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => { }); }); +describe('transport marker', () => { + it('round-trips through create / detect / extract', () => { + const marker = createStalkerAuthFailureMarker('Access denied.'); + expect(isStalkerAuthFailureResponse(marker)).toBe(true); + expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.'); + }); + + it('is what keeps the repair trigger working once Electron classifies', () => { + // The main process replaces the plain-text body with this marker, so + // a detector that only knew the raw string would silently stop + // triggering portal repair on the desktop app. + expect( + isStalkerAuthFailureResponse({ + stalkerAuthFailure: 'Authorization failed.', + }) + ).toBe(true); + }); + + it('does not accept arbitrary objects as markers', () => { + expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe( + false + ); + expect(extractStalkerAuthFailureBody({})).toBeNull(); + }); +}); + +describe('extractStalkerAuthFailureBody', () => { + it('extracts from the raw PWA payload string', () => { + expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe( + 'Authorization failed.' + ); + }); + + it('extracts from a {js: body} envelope', () => { + expect( + extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' }) + ).toBe('Authorization failed.'); + }); + + it('reports no canonical body for the structured panel wording', () => { + // Still an auth failure — just not one of the middleware's bodies. + const response = { js: { error: 'Invalid token' } }; + expect(extractStalkerAuthFailureBody(response)).toBeNull(); + expect(isStalkerAuthFailureResponse(response)).toBe(true); + }); + + it('returns null for ordinary responses', () => { + expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull(); + expect(extractStalkerAuthFailureBody(undefined)).toBeNull(); + }); +}); + describe('isStalkerAuthFailureMessage', () => { it('accepts messages our own auth layer produces', () => { expect( diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts index eb2d44edc..39927e0db 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts @@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean { } /** - * Whether a portal response is an authorization failure in EITHER wire - * shape: the middleware's plain-text body, or the JSON envelope some panels - * answer instead. Classification and the lazy-repair trigger must use this, - * not the string-only primitive: a JSON-failing panel would otherwise be - * persisted as token-free and never repaired. + * Structured shape a transport returns in place of the raw plain-text body. + * + * Returned, never thrown: `ipcRenderer.invoke` strips custom properties from + * rejected values, so a classified body would not survive the trip to the + * renderer as an error. The Electron main process mints this because it is + * where the body arrives; the PWA proxy still delivers the raw string, and + * every consumer goes through the predicates below rather than caring which. + */ +export interface StalkerAuthFailureMarker { + stalkerAuthFailure: StalkerAuthFailureBody; +} + +export function createStalkerAuthFailureMarker( + body: StalkerAuthFailureBody +): StalkerAuthFailureMarker { + return { stalkerAuthFailure: body }; +} + +function readAuthFailureMarker( + value: unknown +): StalkerAuthFailureBody | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + return classifyStalkerAuthFailureBody( + (value as StalkerAuthFailureMarker).stalkerAuthFailure + ); +} + +/** + * Extracts the canonical failure body from any shape an auth failure reaches + * a consumer in: the transport marker (Electron), the raw plain-text payload + * (PWA proxy), or a `{js: ''}` envelope. Returns null for everything + * else, including the structured `{js: {error|msg}}` form — that one is a + * panel's own wording, not one of the middleware's three bodies, so it has + * no canonical body to report. Use `isStalkerAuthFailureResponse` when the + * question is merely "did authorization fail?". + */ +export function extractStalkerAuthFailureBody( + value: unknown +): StalkerAuthFailureBody | null { + return ( + readAuthFailureMarker(value) ?? + classifyStalkerAuthFailureBody(value) ?? + classifyStalkerAuthFailureBody( + typeof value === 'object' && value !== null && 'js' in value + ? (value as { js?: unknown }).js + : undefined + ) + ); +} + +/** + * Whether a portal response is an authorization failure in ANY wire shape: + * the middleware's plain-text body, the transport marker minted from it, or + * the JSON envelope some panels answer instead. Classification and the + * lazy-repair trigger must use this, not the string-only primitive: a + * JSON-failing panel would otherwise be persisted as token-free and never + * repaired, and an Electron-classified body would stop triggering repair at + * all. */ export function isStalkerAuthFailureResponse(response: unknown): boolean { if (isStalkerAuthFailureBody(response)) { return true; } + if (readAuthFailureMarker(response) !== null) { + return true; + } + if ( response === null || typeof response !== 'object' || 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; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 39eefd3e7..f8fedcd72 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -157,9 +157,7 @@ } @else if (isStalkerCategoryFailed()) { } @else { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index fc2927f01..102c124d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerStore, + asStalkerPortalError, +} from '@iptvnator/portal/stalker/data-access'; import { PortalCategorySortMode, persistPortalCategorySortMode, @@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent { this.stalkerStore.isCategoryResourceLoading; readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed; + /** + * When category loading failed because the portal refused the session, + * the portal's own explanation (msg/block_msg or the documented + * plain-text failure body) replaces the generic hint; a login-required + * refusal gets its own actionable text. + */ + readonly stalkerCategoryErrorDescription = computed(() => { + const portalError = asStalkerPortalError( + this.isStalkerCategoryFailed() + ); + if (portalError?.portalText) { + return portalError.portalText; + } + if (portalError?.kind === 'login-required') { + return this.translate.instant( + 'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED' + ); + } + return this.translate.instant( + 'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' + ); + }); // Category count badges are only available for Stalker Live TV, where the // full channel list is cached — VOD/series/radio still page lazily, so // their per-category totals are unknown. diff --git a/package.json b/package.json index 1ce88c3da..3f6e0dbd0 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@ngrx/store-devtools": "21.1.1", "@ngx-pwa/local-storage": "21.0.0", "@yangkghjh/videojs-aspect-ratio-panel": "0.0.1", - "angularx-qrcode": "21.0.4", + "angularx-qrcode": "21.0.5", "artplayer": "5.4.0", "axios": "1.18.1", "better-sqlite3": "12.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57dde949a..758c1a4f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,8 +95,8 @@ importers: specifier: 0.0.1 version: 0.0.1 angularx-qrcode: - specifier: 21.0.4 - version: 21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 21.0.5 + version: 21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) artplayer: specifier: 5.4.0 version: 5.4.0 @@ -5104,8 +5104,8 @@ packages: typescript: '*' typescript-eslint: ^8.0.0 - angularx-qrcode@21.0.4: - resolution: {integrity: sha512-GZFa/X/3rHx/4peA4zNROkK6UaYqxJX0dgkEMk7dCcoYNwJM8/UkOkEUfcx+Btjr7iT4UEhf9twWhFjFp58wfw==} + angularx-qrcode@21.0.5: + resolution: {integrity: sha512-9Kv/Mi5tKzAkBL2xhYZyPaJEoq+b24mIpUAntp16kIBiozVnpGI6DbigTPJiSDkV7EpYHdjBJNGj8igFAHEp6g==} peerDependencies: '@angular/common': ^21.0.0 '@angular/core': ^21.0.0 @@ -5347,6 +5347,10 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} @@ -6921,6 +6925,10 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -7330,6 +7338,10 @@ packages: resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -7417,8 +7429,8 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -7976,6 +7988,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonparse@1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} @@ -8188,6 +8203,10 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} + engines: {node: ^20.17.0 || >=22.9.0} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -8475,6 +8494,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -8508,6 +8531,10 @@ packages: resolution: {integrity: sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==} engines: {node: ^20.17.0 || >=22.9.0} + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + minipass-flush@1.0.7: resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} engines: {node: '>= 8'} @@ -10376,6 +10403,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -10684,6 +10715,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -13076,7 +13108,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.5 + fs-extra: 11.4.0 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -13835,7 +13867,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -15968,7 +16000,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.4 + minimatch: 10.2.6 '@tybys/wasm-util@0.10.1': dependencies: @@ -16662,7 +16694,7 @@ snapshots: - chokidar - supports-color - angularx-qrcode@21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): + angularx-qrcode@21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) @@ -17081,6 +17113,8 @@ snapshots: balanced-match@4.0.3: {} + balanced-match@4.0.4: {} + base-64@1.0.0: {} base64-js@1.5.1: {} @@ -17194,7 +17228,7 @@ snapshots: brace-expansion@5.0.9: dependencies: - balanced-match: 4.0.3 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -18917,6 +18951,13 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -19511,6 +19552,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + icss-utils@5.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -19577,7 +19623,7 @@ snapshots: ip-address@10.1.0: {} - ip-address@10.2.0: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -20320,6 +20366,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + jsonparse@1.3.1: {} keycode@2.2.1: {} @@ -20591,6 +20644,23 @@ snapshots: transitivePeerDependencies: - supports-color + make-fetch-happen@15.0.6: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.0 + '@npmcli/redact': 4.0.0 + cacache: 20.0.3 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -21121,6 +21191,10 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.13 @@ -21163,6 +21237,14 @@ snapshots: optionalDependencies: encoding: 0.1.13 + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.3 + minipass-flush@1.0.7: dependencies: minipass: 3.3.6 @@ -23262,7 +23344,7 @@ snapshots: socks@2.8.9: dependencies: - ip-address: 10.2.0 + ip-address: 10.4.0 smart-buffer: 4.2.0 sorted-array-functions@1.3.0: {} @@ -23380,7 +23462,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: @@ -23437,6 +23519,10 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -23807,7 +23893,7 @@ snapshots: dependencies: '@tufjs/models': 4.1.0 debug: 4.4.3 - make-fetch-happen: 15.0.3 + make-fetch-happen: 15.0.6 transitivePeerDependencies: - supports-color @@ -24448,7 +24534,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index bbdd99343..752a99ecd 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -38,7 +38,6 @@ export const maxLinesBaseline = [ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts', 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts', 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts', - 'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',