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