docs(stalker): document smart endpoint discovery

This commit is contained in:
4gray committed 2026-08-08 22:06:27 +02:00
1 parent d2d91e322b
commit 8d6fe1e02d
3 files changed
+144 -110

No files matched your search

+135 -102
View File
@@ -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 `<base>/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 `<base>/portal.php`
→ `<base>/server/load.php` → `<base>/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
`<base>/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 `<base>/portal.php` → `<base>/server/load.php` →
`<base>/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 `<base>/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