From 8d6fe1e02dfd65c5c3ebb12eded9930ac12dc808 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Aug 2026 22:06:23 +0200 Subject: [PATCH] docs(stalker): document smart endpoint discovery --- .changes/stalker-endpoint-discovery.md | 9 +- CLAUDE.md | 8 +- docs/architecture/stalker-portal.md | 237 ++++++++++++++----------- 3 files changed, 144 insertions(+), 110 deletions(-) diff --git a/.changes/stalker-endpoint-discovery.md b/.changes/stalker-endpoint-discovery.md index ed93eb173..18e2fe01f 100644 --- a/.changes/stalker-endpoint-discovery.md +++ b/.changes/stalker-endpoint-discovery.md @@ -4,8 +4,7 @@ area: stalker issues: [850, 686, 755] --- -Stalker portals are no longer classified by their URL shape: importing probes -the real API endpoint (`portal.php` vs `server/load.php`) and checks whether -the portal actually requires authentication. Canonical Ministra URLs finally -load content, `…/c` addresses resolve correctly, and misclassified existing -portals repair themselves on first failure — keeping favorites and history. +Stalker setup now accepts a host or `/c` address, finds the working API +endpoint and authentication mode, and rechecks changed connection details in +Edit. Misclassified portals from earlier versions still repair themselves on +first compatible failure without losing favorites or history. diff --git a/CLAUDE.md b/CLAUDE.md index 2d4cd9a30..33aae3042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1166,15 +1166,17 @@ engine` (restart required) or **Stalker Portal Mode and Endpoint Discovery**: - Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one. -- Import probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists the proven endpoint and mode. -- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair *drives*, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. +- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `/portal.php` → `/server/load.php` → `/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `/portal.php`, while canonical-shaped unreachable addresses still abort. +- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Failure writes nothing; success atomically replaces endpoint, mode, normalized identity and session metadata, then synchronizes the active `StalkerStore` snapshot and session/watchdog before another same-route request can use the old connection. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape. +- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed. +- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit. - Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them. - Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream. - Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding"). **Stalker Session Authentication**: -- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`. +- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-authenticated-request-client.ts`, `stalker-edited-session-coordinator.ts` (authoritative Edit/session serialization), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`. - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. 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` / `device-conflict` / `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. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - 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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ebe31290d..4c122bf80 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -60,41 +60,42 @@ below is reached as `/workspace/stalker/:id/…`. `DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks the lazy portal repair (see "Portal Mode and Endpoint Discovery"). - Four callers deliberately sit outside it and issue `STALKER_REQUEST` - themselves, because each one runs *below* or *before* what it routes on: + Four callers deliberately sit outside it and issue `STALKER_REQUEST` + themselves, because each one runs _below_ or _before_ what it routes on: - - `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the - full-portal branch is implemented in terms of, so routing them back - through it would recurse. - - `StalkerPortalDiscoveryService` — probes run before a mode exists; the - mode is what they are determining. - - `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is - a profile request, so it takes the same exemption as the auth layer. - - `StreamResolverService`, for a collection item carrying its own portal - coordinates with **no playlist row** — there is no meta to route or - repair with. The playlist-backed branch beside it does use - `executeStalkerRequest()`, and wins when a row exists, so a repaired - endpoint beats a stale favorite's snapshot. + - `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the + full-portal branch is implemented in terms of, so routing them back + through it would recurse. + - `StalkerPortalDiscoveryService` — probes run before a mode exists; the + mode is what they are determining. + - `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is + a profile request, so it takes the same exemption as the auth layer. + - `StreamResolverService`, for a collection item carrying its own portal + coordinates with **no playlist row** — there is no meta to route or + repair with. The playlist-backed branch beside it does use + `executeStalkerRequest()`, and wins when a row exists, so a repaired + endpoint beats a stale favorite's snapshot. - The exemption is from the routing, not from the repair it hooks — but only - **one** of the four wires `StalkerPortalRepairService` itself, and the - asymmetry is worth knowing before adding a fifth: + The exemption is from the routing, not from the repair it hooks — but only + **one** of the four wires `StalkerPortalRepairService` itself, and the + asymmetry is worth knowing before adding a fifth: - - `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly, - because opening the account dialog on a playlist with a stale endpoint - must be able to fix it instead of waiting for an unrelated catalog - request. - - `StalkerPortalDiscoveryService` is what repair *drives*, so it cannot - repair itself. - - The auth layer wires nothing. It does not need to: a terminal handshake - failure propagates out of the full-portal branch and is caught by - whichever `executeStalkerRequest()` call triggered the authentication, - which is exactly why "terminal handshake failures" is one of the repair - triggers listed above. - - `StreamResolverService`'s row-less branch has no playlist to repair. + - `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly, + because opening the account dialog on a playlist with a stale endpoint + must be able to fix it instead of waiting for an unrelated catalog + request. + - `StalkerPortalDiscoveryService` is what repair _drives_, so it cannot + repair itself. + - The auth layer wires nothing. It does not need to: a terminal handshake + failure propagates out of the full-portal branch and is caught by + whichever `executeStalkerRequest()` call triggered the authentication, + which is exactly why "terminal handshake failures" is one of the repair + triggers listed above. + - `StreamResolverService`'s row-less branch has no playlist to repair. + + Anything new that is not auth or discovery belongs on + `executeStalkerRequest()`. - Anything new that is not auth or discovery belongs on - `executeStalkerRequest()`. 4. Electron main process handles `STALKER_REQUEST` in `apps/electron-backend/src/app/events/stalker.events.ts`. 5. Axios calls the portal's persisted API endpoint (`portal.php` on @@ -135,20 +136,20 @@ Two portal modes exist, persisted per playlist as `Accept`/`Accept-Language`/`Connection` set (see "Request Transport and `cmd` Encoding"). - What it does **not** get is anything carried by the session. The direct - branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and - `params`, so the builder never sees a token *or* a serial: no - `Authorization: Bearer` (there is none to send), and no `SN` header or - serial-derived `__cfduid` cookie **even when the playlist stores a serial**. - The full-portal branch forwards both, because `makeAuthenticatedRequest()` - passes `token` and `serialNumber`. This covers portal API requests only — - playback headers are built from the playlist row by a different helper and - are NOT mode-gated, so the same simple-mode playlist does send its serial - with a portal-owned stream (see "Stalker Identity Policy"). - Whether a simple panel ought to receive - the serial is unproven: no reference portal is known to require it, and the - `sn` *parameter* only ever travels on `get_profile`, which a simple portal - never calls. Treat it as an open question rather than a bug to fix blind. + What it does **not** get is anything carried by the session. The direct + branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and + `params`, so the builder never sees a token _or_ a serial: no + `Authorization: Bearer` (there is none to send), and no `SN` header or + serial-derived `__cfduid` cookie **even when the playlist stores a serial**. + The full-portal branch forwards both, because `makeAuthenticatedRequest()` + passes `token` and `serialNumber`. This covers portal API requests only — + playback headers are built from the playlist row by a different helper and + are NOT mode-gated, so the same simple-mode playlist does send its serial + with a portal-owned stream (see "Stalker Identity Policy"). + Whether a simple panel ought to receive + the serial is unproven: no reference portal is known to require it, and the + `sn` _parameter_ only ever travels on `get_profile`, which a simple portal + never calls. Treat it as an open question rather than a bug to fix blind. Neither label is tied to a URL shape: mode follows OBSERVED behavior, so a `portal.php` panel that enforces the token is classified — and treated @@ -164,31 +165,58 @@ copies of this rule existed (import, session service, legacy-flag migration) and their drift shipped broken configurations (#850, #686, #755); no new consumer may re-implement the rule. -**Endpoint discovery (import).** `portal.php` does not exist in official -Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint -derived from a `…/c` URL is `/server/load.php`. Instead of guessing -from the URL shape, `StalkerPortalDiscoveryService` -(`libs/portal/stalker/data-access`) probes candidates in order — the pasted -URL itself when it already names a `.php` endpoint, then `/portal.php` -→ `/server/load.php` → `/stalker_portal/server/load.php` — and -classifies each endpoint by observed behavior: a token-less +**Endpoint discovery (import and edit).** The address field requires an +explicit `http://` or `https://` scheme, but accepts a bare host, a browser +entry point such as `…/c`, or a concrete `.php` API endpoint. `portal.php` +does not exist in official Stalker/Ministra — it is a reseller-panel alias; +the canonical endpoint derived from a `…/c` URL is +`/server/load.php`. Instead of guessing from the URL shape, +`StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) probes +candidates in order — the pasted URL itself when it already names a `.php` +endpoint, then `/portal.php` → `/server/load.php` → +`/stalker_portal/server/load.php` — and classifies each endpoint by +observed behavior: a token-less `itv/get_genres` that returns data proves a token-free panel; the plain-text auth failure proves the endpoint enforces the token, which is confirmed by running the real handshake + `get_profile`. The import dialog persists the -proven endpoint and mode. When no candidate answers at all, panel-style URLs -fall back to the pre-discovery behavior (legacy `…/c` → `portal.php` rewrite, -simple mode, import succeeds with a warning) so temporarily offline panels -can still be added; canonical-shaped URLs abort like the old mandatory -handshake did (both classifications run on the normalized -`origin + pathname` form). Probe failure sequencing: ANY resolvable HTTP -status moves to the next candidate — 4xx means the endpoint is absent, a -5xx can be one broken handler beside a healthy sibling — and 401/403 -specifically classify as auth-required (the handshake is attempted, for -middlewares that answer HTTP auth codes instead of the stock 200 + +proven endpoint and mode, and that resolved API endpoint is what Edit shows. +When no candidate answers at all, panel-style URLs fall back to the +pre-discovery behavior (a bare host becomes `/portal.php`; legacy +`…/c` becomes the matching `portal.php`; simple mode, import succeeds with a +warning) so temporarily offline panels can still be added. Canonical-shaped +URLs abort like the old mandatory handshake did (both classifications run on +the normalized `origin + pathname` form). Probe failure sequencing: ANY +resolvable HTTP status moves to the next candidate — 4xx means the endpoint +is absent, a 5xx can be one broken handler beside a healthy sibling — and +401/403 specifically classify as auth-required (the handshake is attempted, +for middlewares that answer HTTP auth codes instead of the stock 200 + plain-text body). Status-less TIMEOUTS also continue (a single handler can hang); only connection-level failures (refused, unresolvable host) abort discovery, since every candidate shares the host. +The playlist-info Edit dialog compares URL, MAC, username/password, serial, +device IDs and signatures as one connection identity. A metadata-only edit +does not run discovery and preserves the stored connection byte-for-byte. +Changing any connection field disables the form while the same discovery +service validates the draft. Auth rejection or an unreachable portal leaves +the dialog open and writes nothing. Success atomically replaces the endpoint, +mode and normalized identity together with session metadata: simple mode +clears token/fingerprint/watchdog/account state, while full mode replaces it +with the confirmed authorization result. The app adapter also replaces the +active `StalkerStore` snapshot and session/watchdog state immediately, so the +next request in the same route cannot use the pre-edit connection. +`PlaylistMetaUpdate` carries the persisted part as a transient +`stalkerSessionPatch` (`undefined` preserves, `null` clears, an object fully +replaces); `PlaylistsService` projects it onto the existing flat playlist +fields, so the patch itself never enters SQLite, IndexedDB or a backup and no +schema or backup-version migration is required. + +The shared playlist UI exposes only +`STALKER_PLAYLIST_CONNECTION_EDITOR` and its +`resolved | auth-rejected | unreachable` result contract. The web composition +layer implements the token with Stalker data-access; the UI library must not +import Stalker discovery directly. + **Lazy repair (existing playlists).** The flag is frozen in the DB, so records persisted by the old guess stay broken without repair — but a large share of users are on working reseller panels, and only probing can tell the @@ -209,7 +237,10 @@ keep working) and persisted through `PlaylistsService.transformPlaylistMeta` queue, so a user edit that is queued but not yet committed wins over the repair instead of being overwritten; the transform patches the freshly read row (`portalUrl` + `isFullStalkerPortal` only, so user state can never be -clobbered) and returns null to abort. Deletion runs through the same queue, +clobbered) and returns null to abort. Explicit Edit also advances an in-run +generation before replacing the session, so a repair that already verified +its row but finishes later cannot install its older override or token. +Deletion runs through the same queue, so a repair can never resurrect a playlist deleted mid-probe. Portals that work are never probed, let alone rewritten. E2E coverage: `apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts` against the @@ -288,8 +319,8 @@ describes the emulated box, not the account — see "Reported device profile".) and reused for initial auth, token refresh, retry auth, normal API requests, and same-origin playback headers. - Those last two reach the wire by different routes, and **only one is - mode-gated** — the asymmetry is easy to get backwards: + Those last two reach the wire by different routes, and **only one is + mode-gated** — the asymmetry is easy to get backwards: - **Portal API requests** receive the serial through `makeAuthenticatedRequest()`, which only the full-portal branch of @@ -299,9 +330,10 @@ describes the emulated box, not the account — see "Reported device profile".) - **Same-origin playback headers** are built by `buildStalkerExternalPlaybackHeaders()`, which reads `playlist.stalkerSerialNumber` straight off the row with no mode check - at all. So a simple-mode playlist holding a real serial *does* send `SN` + at all. So a simple-mode playlist holding a real serial _does_ send `SN` and the serial-derived `__cfduid` on portal-owned streams — while its API calls do not. + - Empty optional identity fields remain absent. IPTVnator must never generate a device ID behind the user's back, and must never duplicate `device_id2` from `device_id1` on its own. @@ -343,7 +375,7 @@ never rewritten on read: - rewriting them at the transport would move `stalkerSessionFingerprint` for every existing playlist at once, with no user action and no way to opt out. -An edit therefore *does* move the fingerprint and force a re-authentication. +An edit therefore _does_ move the fingerprint and force a re-authentication. That is intended: the user changed the identity, and the field shows exactly what will be sent. @@ -357,7 +389,7 @@ MAC. Refusing one would stop those users adding or editing a portal that works for them. The mock encodes the same split (`enforceMacFormat` is set only on the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in `stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict -endpoint and be refused *there*, not in the form. +endpoint and be refused _there_, not in the form. In the edit dialog **both** passes — blur and submit — normalize only a MAC the user actually changed, compared against the value the dialog was opened with @@ -425,10 +457,11 @@ later empty value as a permanent lockout. Therefore: deliberately emptied, and the import would pin IDs the user opted out of. - All three are mutation-verified. Note the tests have to control when the - digest settles (hold it behind a gate, or delay the older invocation): - Node resolves digests this small in start order, so the naive versions pass - with the guards removed; + All three are mutation-verified. Note the tests have to control when the + digest settles (hold it behind a gate, or delay the older invocation): + Node resolves digests this small in start order, so the naive versions pass + with the guards removed; + - **the MAC and its device IDs travel as one value.** `settleMacAddressIdentity()` reads the MAC once, before it awaits, and returns it together with the IDs derived from exactly it; `addPlaylist()` @@ -459,17 +492,17 @@ later empty value as a permanent lockout. Therefore: out" would be false and would discourage them from fixing an ID that was never pinned. - **Known imprecision, deliberately not closed here.** The gate proves that - *some* device ID reached the portal, not that the currently stored one did: - a user who edits the ID after import keeps `isFullStalkerPortal` true while - the new value has never seen `get_profile`. The copy is hedged for exactly - that ("a device ID", not "this one") and the fields stay editable, so the - remedy — restoring the ID that was pinned — is never blocked. Making it - exact needs a per-identity confirmation signal; - `Playlist.stalkerSessionIdentity` already carries a session fingerprint that - would serve, but reading it here would make a `type:ui` playlist library - depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth - revisiting behind a shared contract, not worth a boundary exception. + **Known imprecision, deliberately not closed here.** The gate proves that + _some_ device ID reached the portal, not that the currently stored one did: + a user who edits the ID after import keeps `isFullStalkerPortal` true while + the new value has never seen `get_profile`. The copy is hedged for exactly + that ("a device ID", not "this one") and the fields stay editable, so the + remedy — restoring the ID that was pinned — is never blocked. Making it + exact needs a per-identity confirmation signal; + `Playlist.stalkerSessionIdentity` already carries a session fingerprint that + would serve, but reading it here would make a `type:ui` playlist library + depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth + revisiting behind a shared contract, not worth a boundary exception. The trade-off the option exists for is interoperability, not obfuscation: a user who reaches the same account from StbEmu already has this exact value @@ -553,7 +586,7 @@ 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 +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. @@ -785,15 +818,15 @@ 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()`. | +| 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: @@ -915,14 +948,14 @@ A temporary link lives about 5 seconds (`tv_tmp_link_ttl` / 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. | +| 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