diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md
index 832b833b7..0ebfffadb 100644
--- a/.codex/skills/stalker-portal/SKILL.md
+++ b/.codex/skills/stalker-portal/SKILL.md
@@ -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.
diff --git a/CLAUDE.md b/CLAUDE.md
index d7e0dfacb..fd09cb7d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 `/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.
+- 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.
diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md
index ed222f3ce..96edfaf5d 100644
--- a/apps/stalker-mock-server/README.md
+++ b/apps/stalker-mock-server/README.md
@@ -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=&macAddress=&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/.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=&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=` | `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=&...` 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=&...` 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=&...` 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
diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts
index f6798b37a..738d94039 100644
--- a/apps/stalker-mock-server/src/main.ts
+++ b/apps/stalker-mock-server/src/main.ts
@@ -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');
diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts
index adee8a21c..07459da9e 100644
--- a/apps/web-e2e/playwright.config.ts
+++ b/apps/web-e2e/playwright.config.ts
@@ -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: [
{
diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md
index b45eedae0..71d955448 100644
--- a/docs/architecture/playlist-backup-restore.md
+++ b/docs/architecture/playlist-backup-restore.md
@@ -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
diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md
index 2c193f90d..2d2909452 100644
--- a/docs/architecture/stalker-mock-server.md
+++ b/docs/architecture/stalker-mock-server.md
@@ -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>` 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=` 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.
diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md
index 309d00ce1..45d824421 100644
--- a/docs/architecture/stalker-portal.md
+++ b/docs/architecture/stalker-portal.md
@@ -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
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
index f31a6c613..2af5538eb 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts
@@ -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',
diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
index 7659936f6..58d0dc6bf 100644
--- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
+++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts
@@ -123,13 +123,36 @@ async function dispatchStalkerRequest(
}
/**
- * 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(
deps: StalkerRequestDeps,
diff --git a/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts b/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts
index 5c2c34a7b..b60d56970 100644
--- a/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts
+++ b/libs/ui/playback/src/lib/web-player-view/electron-stream-headers.service.ts
@@ -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 —