docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)

Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.

Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).

CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".

Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.

The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.

Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.

Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-08-06 17:49:49 +02:00
1 parent 53c318bac7
commit 5e4f2ca3dd
11 files changed
+319 -75

No files matched your search

+20 -4
View File
@@ -14,14 +14,30 @@ description: Use when changing Stalker or Ministra routes, stores, catalog or se
## Ownership
- Routed UI: `libs/portal/stalker/feature/src/lib/`
- API, session, store, and normalization:
- Session, store, and normalization:
`libs/portal/stalker/data-access/src/lib/`
- Wire-format and identity contracts: `libs/shared/interfaces/src/lib/` —
portal-mode predicate, MAC/device-ID utils, auth-failure classifier, `cmd`
encoder, URL/identity builders. There because Electron main cannot import
renderer libs; never fork them.
- Electron transport: `apps/electron-backend/src/app/events/stalker.events.ts`
- Provider-neutral collections: `libs/portal/shared/data-access/src/lib/`
Keep Stalker request and shape rules in Stalker data access. Shared portal UI
Keep Stalker shape and store rules in Stalker data access. Shared portal UI
must remain provider-neutral.
## Portal Mode And Session
Full vs. simple mode is decided by observed behavior, not URL shape, and read
only through `isFullStalkerPortalPlaylist()`. Route playlist-backed catalog,
content and playback calls through `executeStalkerRequest()`. Auth, discovery,
account-profile refresh and row-less collection resolution go direct; read
`stalker-request.utils.ts` before adding a fifth. Repair is lazy per session,
never eager.
Full portals reuse the persisted idempotent handshake token while its session
fingerprint matches, and ping `get_events` at the profile cadence (default
120 s). Auth failures are HTTP 200 plus plain text.
## Series Contract
Inside Stalker portal code, `isStalkerSeriesFlag()` is the canonical predicate
@@ -69,5 +85,5 @@ Run:
`workspace-dashboard-feature` test targets
For the user workflow, run
`pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts` or document the missing
fixture and strongest focused coverage.
`pnpm nx run web-e2e:e2e-ci--src/stalker.e2e.ts` or document the strongest
focused coverage available.
+11 -2
View File
@@ -1111,10 +1111,19 @@ engine` (restart required) or
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
**Stalker 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 `<base>/portal.php` → `<base>/server/load.php` → `<base>/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.
- 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 facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`.
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
- 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`.
- `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.
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
+38 -11
View File
@@ -59,7 +59,10 @@ actually get wrong:
accepted, so a client with a broken token pipeline fails loudly.
- Auth failures come back as **HTTP 200 with a plain-text body**
(`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients
that only check status codes will silently render nothing.
that only check status codes will silently render nothing. The stock server
has a third body, `Access denied.` (blocked account), which the mock does not
produce — the app classifies all three
(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`).
- The handshake is **idempotent**: presenting the MAC's current token returns
that same token instead of rotating it.
- `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any
@@ -98,19 +101,27 @@ flags a real portal sends. Outside the `static-channel-cmd` scenario they are
| Environment Variable | Default | Description |
|---|---|---|
| `PORT` | `3210` | HTTP port the server listens on |
| `HOST` | `127.0.0.1` | Bind address. Loopback by default — the fixture serves fabricated, unauthenticated content, so set `HOST=0.0.0.0` only to deliberately point a phone or STB at it |
| `NODE_ENV` | `development` | Node environment |
## Utility Endpoints
## Non-Portal Endpoints
Besides the portal paths above, `src/main.ts` mounts:
| Endpoint | Method | Description |
|---|---|---|
| `/stalker?url=<portal_url>&macAddress=<mac>&action=<action>&…` | `GET` | CORS-proxy mirror of the IPTVnator web-backend `/stalker` endpoint, used by PWA/Playwright runs. `macAddress`, `token` and `serialNumber` are control params turned into headers and stripped from the portal query (except `handshake`'s candidate token); the response is wrapped as `{ payload }`. Strictness follows the proxied `url`'s shape |
| `/stream/gated/:file` | `GET` | `video.mp4` / `audio.mp4` fixtures for the `gated-stream` scenario — 403 without the mac cookie **and** the MAC's current Bearer token |
| `/assets/marketing/poster/<slug>.png` | `GET` | The committed screenshot-safe poster catalog shared with the Xtream mock, served from this process so `marketing-demo` needs no second server |
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) |
| `/reset[?macAddress=<mac>&macAddress=…]` | `POST` | Clear generated data, favorites, session/auth state (including pinned device IDs) and watchdog counters. **Pass the MACs you own**: state is per-MAC and parallel spec files share this process, so a bare `/reset` wipes their state mid-test. Repeated params clear several MACs in one request |
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal |
## API Coverage
All endpoints are served at `GET /portal.php?action=<action>&...` matching the real Stalker protocol:
Every action is served by the same dispatcher (`src/app/routes/dispatch.ts`) at
every portal path — `GET /portal.php?action=<action>&...` and the strict
`server/load.php` shapes alike — matching the real Stalker protocol:
| Action | Description |
|---|---|
@@ -118,11 +129,12 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format |
| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) |
| `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise |
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
| `get_main_info` | `account_info/get_main_info` — simple-mode subscription facts for the Stalker account-info dialog, in the nested `js.account_info` envelope |
| `get_categories` | Category list filtered by `type` (itv/vod/series); `get_genres_itv` / `get_genres_vod` are handled by the same handler |
| `get_genres` | Genre list (mirrors categories) |
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
| `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario |
| `create_link` | Returns a real public HLS stream URL for playback |
| `create_link` | Returns a playable stream URL — a real public HLS stream, or the credential-gated local URL in the `gated-stream` scenario. Also echoes the mock-only `cmd_received` / `query_keys_received` diagnostics that pin the client's `cmd` wire format |
| `favorites` | Add / remove / get favorites (in-memory, resets on restart) |
| `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) |
| `get_epg_info` | Bulk EPG keyed by channel id for a requested `period` window |
@@ -159,7 +171,15 @@ nx e2e web-e2e
nx e2e web-e2e --grep "@stalker"
```
The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests, and calls `POST /reset` in `beforeEach` to ensure a clean state between tests.
The test suite uses `00:1A:79:00:00:01` (default scenario) for most tests. State
is per-MAC and several spec files share this one server process, so `beforeEach`
resets **only the MACs the file owns** (`OWNED_MACS` in `stalker.e2e.ts`, sent as
repeated `macAddress` params in a single request) rather than clearing
everything. The sibling specs that talk to this server (`self-hosted.e2e.ts`,
the `sources-pwa` helpers) own a disjoint `00:1A:79:5F:*` range for the same
reason, and `stalker.e2e.ts` runs `mode: 'serial'` because its tests
deliberately share scenario MACs. Full contract:
[`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md#test-isolation).
## EPG Behavior
@@ -182,25 +202,32 @@ See [`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker
```
apps/stalker-mock-server/
├── src/
│ ├── main.ts # Express bootstrap
│ ├── main.ts # Express bootstrap + non-portal routes
│ └── app/
│ ├── scenarios.ts # MAC → scenario config mapping
│ ├── data-generator.ts # Seeded faker data generation
│ ├── data-store.ts # Lazy per-MAC in-memory cache
│ ├── auth-store.ts # Tokens, device pinning, do_auth state
│ ├── request-mac.ts # MAC read from the `mac=` cookie
│ ├── marketing-poster-url.ts # Origin-resolved poster paths
│ ├── routes/
│ │ ├── portal.route.ts # /portal.php route
│ │ ├── portal.route.ts # Portal router (tolerant + strict)
│ │ └── dispatch.ts # Shared Stalker action dispatcher
│ └── handlers/
│ ├── handshake.handler.ts
│ ├── get-profile.handler.ts
│ ├── get-events.handler.ts
│ ├── do-auth.handler.ts
│ ├── get-main-info.handler.ts
│ ├── get-categories.handler.ts
│ ├── get-genres.handler.ts
│ ├── get-ordered-list.handler.ts
│ ├── get-all-channels.handler.ts
│ ├── get-seasons.handler.ts
│ ├── create-link.handler.ts
│ ├── favorites.handler.ts
│ ├── get-epg-info.handler.ts
│ ├── get-short-epg.handler.ts
│ └── get-genres.handler.ts
│ └── get-short-epg.handler.ts
├── project.json
├── tsconfig.json
└── README.md
+10 -5
View File
@@ -105,11 +105,16 @@ app.use('/server/load.php', createPortalRouter(true));
app.use('/ministra/server/load.php', createPortalRouter(true));
/**
* Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks
* `/stalker_portal/` or `/server/load.php`; import-time normalization checks
* `/stalker_portal`). Any URL shape the client would authenticate against must
* be enforced by the proxy too, or tests would silently exercise the tolerant
* branch.
* Which proxied portal URLs this mock enforces the token on. It mirrors
* `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` — the union of
* the three predicates that used to diverge in the app before endpoint
* discovery unified them.
*
* The app itself no longer classifies by URL shape (mode is an observed,
* persisted fact), but a fixture has to decide strictness from the path
* alone: it IS the behavior being observed. Every URL shape the client would
* authenticate against must be enforced here, or tests silently exercise the
* tolerant branch.
*/
function isFullPortalUrlShape(url: string): boolean {
return url.includes('/stalker_portal') || url.includes('/server/load.php');
+6 -1
View File
@@ -51,7 +51,12 @@ export default defineConfig({
},
/* Run local dev servers before starting the tests.
* Both the Angular app and the Stalker mock server start in parallel.
* Set MOCK_PORT to override the default mock server port (3210).
*
* MOCK_PORT only moves where the CLIENT looks — this health check and the
* specs' MOCK_SERVER constants. The mock reads PORT, which
* stalker-mock-server's serve target pins to 3210, and nothing maps one to
* the other, so MOCK_PORT alone makes the wait below time out. Use it to
* point at a mock you started yourself on that port.
*/
webServer: [
{
+3 -1
View File
@@ -89,9 +89,11 @@ state.
- favorites snapshots
- recently viewed snapshots
Explicitly excluded:
Explicitly excluded — session state, as opposed to the connection definition:
- `stalkerToken`
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the profile-advertised cadence)
- `stalkerAccountInfo`
- playback positions in v1
+55 -12
View File
@@ -102,6 +102,13 @@ tests can assert the client re-handshakes and retries instead of surfacing an
error; pinned device identity survives invalidation, as it does on a real
portal.
Beside the portal endpoints, `main.ts` mounts a handful of non-portal routes:
`GET /stalker` (the PWA CORS-proxy mirror), `GET /stream/gated/:file` (the
credential-gated media fixtures for the `gated-stream` scenario),
`GET /assets/marketing/poster/*` (the committed screenshot-safe posters) and
the `/health` + `/reset` + `/invalidate-session` utilities. The full list with
request shapes is in `apps/stalker-mock-server/README.md`.
## Data Generation Pipeline
```
@@ -340,7 +347,9 @@ that no `create_link` request reaches the portal. See
Favorites are stored in a `Map<mac, Set<itemId>>` in `src/app/data-store.ts`. They persist for the lifetime of the server process and are shared across all requests for the same MAC.
Call `POST /reset` to clear all favorites (and regenerated data) between test runs.
Call `POST /reset?macAddress=<mac>` to clear a MAC's favorites (and its cached
generated data) between test runs — see "Test Isolation" for why the scoped
form is the one specs should use.
## Playwright Integration
@@ -348,26 +357,53 @@ Call `POST /reset` to clear all favorites (and regenerated data) between test ru
```typescript
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
},
{ command: webServerCommand /* web:serve */, url: baseURL },
{
command: 'pnpm nx run stalker-mock-server:serve',
url: 'http://localhost:3210/health',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
},
// plus the xtream mock (3211) and web-backend (3333) entries
]
```
Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.
Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance.
**`MOCK_PORT` moves the CLIENT side only** — Playwright's health-check URL and
the `MOCK_SERVER` constants in the specs. The server's own port comes from
`PORT` (`main.ts`), which the `serve` and `serve-with-watch` targets pin to
`3210` in `project.json`, and nothing maps one variable to the other. Setting
`MOCK_PORT` alone therefore points Playwright at a port nothing is listening
on and the run times out waiting for `/health`. It is only useful against a
mock you started yourself on that port (`reuseExistingServer` is on outside
CI); relocating the Nx-managed one would need `MOCK_PORT` passed through as
`PORT`.
### Test Isolation
Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.
Mock state is keyed by MAC and one mock-server process is shared by every spec
file running in parallel workers, so isolation is per-MAC rather than global:
`resetAll()` clears both the generated-content cache and in-memory favorites (`data-store.ts`). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.
- `POST /reset` accepts one or more `?macAddress=` params and clears only those
MACs. A bare `POST /reset` clears everything and is only safe when nothing
else is talking to the server — a spec that used it would wipe a sibling
spec's session mid-test.
- `apps/web-e2e/src/stalker.e2e.ts` declares the MACs it owns in `OWNED_MACS`
and clears exactly those in one batched request. The sibling specs that reach
this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a disjoint
`00:1A:79:5F:*` range, so neither file can clear the other's state.
- Within the file, tests deliberately share scenario MACs (their fixture shapes
are what the assertions are written against), so it pins itself to one worker
with `test.describe.configure({ mode: 'serial' })`.
- A few MACs are deliberately kept OUT of `OWNED_MACS`: the token-reuse test
asserts that a session SURVIVES, so nothing may reset it, and it uses one MAC
per browser project.
A reset drops the generated content cache, favorites (`data-store.ts`), the
auth/session record (`auth-store.ts`, including any pinned device identity) and
the watchdog ping counters. Because generation is seed-deterministic, the next
request regenerates identical content, so the observable data does not change
across resets.
### Recommended Test Structure
@@ -376,9 +412,16 @@ import { test, expect } from '@playwright/test';
const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario
const OWNED_MACS = [MOCK_MAC];
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ request }) => {
await request.post('http://localhost:3210/reset');
// Scoped reset: only the MACs this file owns.
const query = OWNED_MACS.map(
(mac) => `macAddress=${encodeURIComponent(mac)}`
).join('&');
await request.post(`http://localhost:3210/reset?${query}`);
});
test('browse VOD categories', async ({ page }) => {
@@ -393,5 +436,5 @@ test('browse VOD categories', async ({ page }) => {
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness).
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Endpoints With Different Strictness](#endpoints-with-different-strictness).
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.
+132 -27
View File
@@ -30,31 +30,71 @@ Stalker support covers:
## Routing Structure
Primary route tree lives in
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
(`createStalkerRoutes()`), mounted under the workspace shell, so every path
below is reached as `/workspace/stalker/:id/…`.
- `/stalker/:id/vod` (plus `vod/:categoryId` child)
- `/stalker/:id/series` (plus `series/:categoryId` child)
- `/stalker/:id/itv`
- `/stalker/:id/radio`
- `/stalker/:id/favorites`
- `/stalker/:id/recent`
- `/stalker/:id/search`
- `/stalker/:id/actor/:personId`
- `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
- `/stalker/:id/downloads/:downloadId` (focused local movie/series detail with
no category context panel)
- `/workspace/stalker/:id/vod` (plus `vod/:categoryId` child)
- `/workspace/stalker/:id/series` (plus `series/:categoryId` child)
- `/workspace/stalker/:id/itv`
- `/workspace/stalker/:id/radio`
- `/workspace/stalker/:id/favorites`
- `/workspace/stalker/:id/recent`
- `/workspace/stalker/:id/search`
- `/workspace/stalker/:id/actor/:personId`
- `/workspace/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
- `/workspace/stalker/:id/downloads/:downloadId` (focused local movie/series
detail with no category context panel)
`/workspace/stalker/:id` itself redirects to `vod`.
## Runtime Architecture
1. Angular Stalker screens call methods/resources in `StalkerStore`.
2. `StalkerStore` builds request params based on selected content type and current view state.
3. Every portal API call funnels through `executeStalkerRequest()`
3. Catalog, content and playback calls funnel through `executeStalkerRequest()`
(`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts`),
the single choke point that decides the transport per portal mode:
the choke point that decides the transport per portal mode:
full portals go through `StalkerSessionService` (handshake + Bearer token +
retry), token-free panels call
`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:
- `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:
- `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()`.
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
@@ -86,8 +126,34 @@ Two portal modes exist, persisted per playlist as
periodic authenticated `watchdog/get_events` pings at the cadence the
portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog"
below) whose failures are non-fatal.
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
at all — requests carry only the `mac=` cookie.
- **Simple portal** (typically a reseller-style `portal.php` panel): no auth
lifecycle at all — no handshake, no Bearer token, no watchdog. The requests
are not stripped down to a bare cookie either: every Stalker request goes
through the shared identity builder, so a simple portal receives everything
that builder can derive from a MAC alone — the `mac`/`stb_lang`/`timezone`
cookie, the MAG `User-Agent` / `X-User-Agent` pair and the
`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.
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
everywhere — as a full portal, and a canonical `server/load.php` endpoint that
answers without one is a simple portal.
The single predicate lives in `@iptvnator/shared/interfaces`
(`stalker-portal-mode.util.ts`): `isFullStalkerPortalPlaylist()` treats the
@@ -209,13 +275,33 @@ Failure-handling rule:
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
import UI can capture optional serial number, device IDs, and signatures, but
blank fields are not generated or forwarded to `get_profile`.
import UI can capture optional serial number, device IDs, and signatures, but a
field the user leaves blank stays blank — nothing is invented for it, and
nothing empty is forwarded to `get_profile`. The one way a value appears
without being typed is the explicit import-time opt-in described under
"Deriving device IDs from the MAC" below, which writes into the visible fields
first. (The fixed MAG250 description `get_profile` reports is separate: it
describes the emulated box, not the account — see "Reported device profile".)
- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2`
values are trimmed, persisted under the canonical `stalker*` playlist fields,
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:
- **Portal API requests** receive the serial through
`makeAuthenticatedRequest()`, which only the full-portal branch of
`dispatchStalkerRequest()` calls. A simple-mode playlist therefore sends
no serial on any API call, whatever it has stored (see "Portal Mode and
Endpoint Discovery").
- **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`
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.
@@ -296,7 +382,10 @@ dialog. Newly typed values are still held to the format.
### Deriving device IDs from the MAC
`deriveStalkerDeviceIdsFromMac` (`stalker-identity.utils.ts`) returns the pair
`deriveStalkerDeviceIdsFromMac`
(`libs/shared/interfaces/src/lib/stalker-identity.utils.ts` — note the
same-named file in `libs/portal/stalker/data-access` is a different module)
returns the pair
StbEmu and `stalker-to-m3u` generate: uppercase hex `SHA256` of the canonical
MAC for `device_id`, and of that MAC plus a `stalker` salt for `device_id2`.
The import dialog offers it behind an opt-in checkbox that fills both fields.
@@ -407,7 +496,8 @@ nothing to them.
Full portals authenticate through `StalkerSessionService`
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
is a facade over three focused modules:
is a thin facade over focused modules (it was split when the single file
outgrew the `max-lines` budget; never re-add it to the baseline):
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
requests and the `authenticate()` orchestration.
@@ -478,7 +568,11 @@ every start.
`msg`/`block_msg` carry the portal's own explanation; they are
markup-stripped, combined, and thrown as `StalkerPortalError`. The kind is
`device-conflict` when `isStalkerDeviceConflictMessage` matches the combined
text, otherwise `blocked` — see "Device conflicts" below.
text, otherwise `blocked` — see "Device conflicts" below. A **bare**
`{status: 1}` with no message is a refusal too: it used to be read as success
whenever the portal sent no `msg`, which imported dead sources (the stock
MAC-format rejection is exactly that shape). A profile that carries refusal
text without setting the status is likewise refused.
- `status: 2` — login/password required. The client runs `do_auth`
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
@@ -908,11 +1002,18 @@ streams it resolves, so a channel opened from a collection carries the same
credentials as one opened from the portal.
The resolved `ResolvedPortalPlayback.headers` feed both the external players
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
scoped Electron request-header override (`ElectronStreamHeadersService`,
applied by `WebPlayerViewComponent` for the video players and by the Stalker
live layout for the radio audio player, which renders outside
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
"Scoped Request Header Overrides").
scoped Electron request-header override (`ElectronStreamHeadersService` — see
`docs/architecture/electron-security.md`, "Scoped Request Header Overrides").
Three surfaces apply that override, because `WebPlayerViewComponent` owns it
only for the video players and radio renders `AudioPlayerComponent` outside it:
- `WebPlayerViewComponent` — every built-in video player, on every route.
- `StalkerLiveStreamLayoutComponent` — the Stalker radio route's audio player.
- `UnifiedLiveTabComponent` (`libs/portal/shared/ui`) — the radio audio player
of the Favorites / Recently Viewed collection routes.
Each owns a single scope slot and clears it only while it still owns it, so a
handover between them cannot drop the other's credentials.
Two stream profiles exist, selected by one shared predicate:
@@ -1303,9 +1404,13 @@ Exported fields:
- full-portal serial/device/signature fields when present
- favorites and recently viewed collections
Excluded fields:
Excluded fields — everything that describes a negotiated session rather than
the connection:
- `stalkerToken`
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the cadence the profile
advertised)
- `stalkerAccountInfo`
- playback positions in backup v1
@@ -52,8 +52,16 @@ export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL;
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
* Persists tokens during session and handles re-authentication on auth failures.
*
* Handles handshake authentication for playlists in FULL portal mode. Mode is
* a persisted, behavior-observed fact read through
* `isFullStalkerPortalPlaylist()` — never a URL substring: since endpoint
* discovery, a token-enforcing `portal.php` panel is a full portal and a
* `server/load.php` endpoint that answers without a token is a simple one.
*
* Tokens are cached in-run and written back to the playlist row, so a session
* survives a restart; both are tagged with the identity fingerprint they were
* negotiated for. Re-authenticates on auth failures.
*/
@Injectable({
providedIn: 'root',
@@ -123,13 +123,36 @@ async function dispatchStalkerRequest<T>(
}
/**
* Single choke point for Stalker API calls. On top of the mode routing it
* hooks the lazy portal repair: when a request fails in a way only a wrong
* persisted endpoint/mode produces (plain-text `Authorization failed.`
* bodies on token-less requests, HTTP 404 on a vanished endpoint, terminal
* handshake failures), the repair service re-probes the portal once per
* session and — only when a different configuration is PROVEN to work —
* the request is retried against it. Healthy portals never probe.
* Choke point for Stalker catalog, content and playback calls. On top of the
* mode routing it hooks the lazy portal repair: when a request fails in a way
* only a wrong persisted endpoint/mode produces (plain-text
* `Authorization failed.` bodies on token-less requests, HTTP 404 on a
* vanished endpoint, terminal handshake failures), the repair service
* re-probes the portal once per session and — only when a different
* configuration is PROVEN to work — the request is retried against it.
* Healthy portals never probe.
*
* Four callers deliberately issue `STALKER_REQUEST` themselves, because each
* runs BELOW or BEFORE what this routes on:
*
* - `StalkerAuthApi` — `handshake`/`get_profile`/`do_auth` are what the
* full-portal branch here is implemented in terms of, so routing them back
* through it would recurse.
* - `StalkerPortalDiscoveryService` — probes precede the mode they determine.
* - `StalkerAccountInfoService.fetchViaProfile()` — 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. Its row-backed branch does come through here.
*
* The exemption is from the ROUTING, not from the repair hooked above — but
* only `fetchViaProfile()` wires `StalkerPortalRepairService` itself.
* Discovery is what repair drives, the row-less branch has no playlist to
* repair, and the auth layer needs nothing: a terminal handshake failure
* propagates out of the full-portal branch below and is caught here, which is
* why it is one of the triggers listed above.
*
* Anything new that is not auth or discovery belongs here.
*/
export async function executeStalkerRequest<T>(
deps: StalkerRequestDeps,
@@ -19,9 +19,10 @@ function getHeaderValue(
* Single owner of the scoped Electron request-header override for built-in
* playback. There is exactly one scoped override slot in the main process, so
* every surface that plays a stream inline goes through this service:
* `WebPlayerViewComponent` for the web video players, and the Stalker live
* layout for the dedicated radio audio player, which never mounts a
* `WebPlayerViewComponent` at all.
* `WebPlayerViewComponent` for the web video players, plus the two surfaces
* that render `AudioPlayerComponent` for radio and therefore never mount a
* `WebPlayerViewComponent` at all — the Stalker live layout, and the unified
* live tab behind the Favorites / Recently Viewed collection routes.
*
* `apply()` extracts the full header set from the resolved playback —
* including the portal Cookie/Authorization that auth-gated streams require —