Files
93c5f1a051 fix(portals): preserve playlist ownership during detail handoffs (#1825)
* fix(portals): preserve playlist ownership during detail handoffs

* fix(portals): reload Stalker categories only for a held destination

Review follow-ups (Greptile, Codex): resetCategories() reloaded the
category resource, and the route session calls it on a portal switch
before the destination is resolved and on teardown, so it asked the
portal being left, and a failed destination lookup could let that answer
repopulate the sidebar. resetCategories() now only clears; the session
calls the new reloadCategories() after installing the destination, and
only when a handoff had already put that playlist in the store (the
owner, and so the resource params, did not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:21:07 +02:00

117 KiB
Raw Permalink Blame History

Stalker Portal Architecture

This document describes the Stalker portal implementation in IPTVnator and where each feature is integrated.

Scope

Stalker support covers:

  • Live TV (itv)
  • Radio (radio)
  • VOD (vod)
  • Series (series)
  • VOD-as-series flows (is_series=1 and embedded series[])
  • Favorites and recently viewed collections
  • Search
  • External player playback (shared Xtream player infrastructure)
  • Remote control for live ITV navigation

Routing Structure

Primary route tree lives in 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/….

  • /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/discover (facet query params; see docs/architecture/tmdb-metadata-enrichment.md)
  • /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. Catalog, content and playback calls funnel through executeStalkerRequest() (libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts), 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 reseller panels, server/load.php on canonical Stalker/Ministra) with required headers/cookies and returns the raw response.data to the renderer; normalization happens in the store feature slices.

Portal Mode and Endpoint Discovery

Two portal modes exist, persisted per playlist as Playlist.isFullStalkerPortal:

  • Full portal (canonical Stalker/Ministra middleware): every request except handshake, get_profile, get_localization, and do_auth requires Authorization: Bearer <token>; auth failures are HTTP 200 with a plain-text body (Authorization failed., Access denied., Unauthorized request.), never a 401/403. Detection of those bodies — and of the JSON envelope ({js: {error|msg}}) some panels answer instead — lives in libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts, so the Electron main process (where the bodies actually arrive) and the renderer classify identically; stalker-portal-discovery.utils.ts re-exports it. The body match is anchored to the whole reply: these are bare phrases, and a substring rule also accepted a short proxy or WAF page containing one, which then triggered portal reclassification against a portal that never answered. The structured js.error/js.msg fields keep the wider phrase set (Invalid token, Auth failed, bare unauthorized), since a panel fills those in deliberately. While a full portal is the active playlist, StalkerSessionService keeps a watchdog running — 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 (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 persisted flag as authoritative and falls back to the URL shape (isFullStalkerPortalUrl(): /stalker_portal or /server/load.php) only for legacy rows where the flag is undefined. Historically three diverging copies of this rule existed (import, session service, legacy-flag migration) and their drift shipped broken configurations (#850, #686, #755); no new consumer may re-implement the rule.

Endpoint discovery (import and edit). The address field requires an explicit http:// or https:// scheme, but accepts a bare host, a browser entry point such as …/c, or a concrete .php API endpoint. portal.php does not exist in official Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint derived from a …/c URL is <base>/server/load.php. Instead of guessing from the URL shape, StalkerPortalDiscoveryService (libs/portal/stalker/data-access) probes candidates in order — the pasted URL itself when it already names a .php endpoint, then <base>/portal.php → <base>/server/load.php → <base>/stalker_portal/server/load.php — and classifies each endpoint by observed behavior: a token-less itv/get_genres that returns data proves a token-free panel; the plain-text auth failure proves the endpoint enforces the token, which is confirmed by running the real handshake + get_profile. The import dialog persists the proven endpoint and mode, and that resolved API endpoint is what Edit shows. When no candidate answers at all, panel-style URLs fall back to the pre-discovery behavior (a bare host becomes <base>/portal.php; legacy …/c becomes the matching portal.php; simple mode, import succeeds with a warning) so temporarily offline panels can still be added. Canonical-shaped URLs abort like the old mandatory handshake did (both classifications run on the normalized origin + pathname form). Probe failure sequencing: ANY resolvable HTTP status moves to the next candidate — 4xx means the endpoint is absent, a 5xx can be one broken handler beside a healthy sibling — and 401/403 specifically classify as auth-required (the handshake is attempted, for middlewares that answer HTTP auth codes instead of the stock 200 + plain-text body). Status-less TIMEOUTS also continue (a single handler can hang); only connection-level failures (refused, unresolvable host) abort discovery, since every candidate shares the host.

The playlist-info Edit dialog compares URL, MAC, username/password, serial, device IDs and signatures as one connection identity. A metadata-only edit does not run discovery; its queued write omits every connection/mode field so the current stored connection stays byte-for-byte, including when the dialog was hydrated before an older discovery result committed. Before enabling a Stalker form, the dialog loads the complete persisted playlist row by ID. Electron's startup metadata projection omits payload-only identity fields, so editing that summary directly could otherwise display and then persist empty serial, device ID, signature, or mode values. Changing any connection field disables the form while the same discovery service validates the draft. Auth rejection or an unreachable portal leaves the dialog open and writes nothing. Escape/backdrop closure is disabled for the validation window. If navigation or another owner starts closing/destroys the dialog while discovery is in flight, a successful result still crosses the atomic persistence boundary: the submitted get_profile may already have pinned the new serial/device identity remotely and cannot be recalled. The late commit uses transformPlaylistMeta() inside the per-playlist write queue to merge only the resolved connection/session fields into the current row, so a newer title, EPG, or other metadata edit wins. The returned merged row feeds the state-only update, while dialog close and success UI are suppressed after destruction. Both the ordinary resolved metadata update and this late transform require the storage-current row to retain the source connection authority captured when Edit began. Electron performs the check inside its per-playlist write queue; PWA performs the read, predicate, and cursor update in one IndexedDB readwrite transaction so another tab cannot interleave a replacement. The one-time legacy mode-flag migration likewise scans and updates rows through one readwrite cursor transaction; it never replays a snapshot collected before another tab's delete/restore or replacement. Delete/restore or replacement under the same ID therefore aborts instead of receiving a portal/session write. A row identified by its persisted portalUrl stays on the Stalker save path even if legacy Xtream fields remain, so an unrelated Xtream write cannot strand the Edit reservation. Before discovery starts, PWA Edit acquires a shared playlist-authority barrier plus an exclusive origin-wide Web Lock keyed by playlist ID and, while holding both, verifies that the persisted row still has the source connection shown when Edit began. Add/delete, backup restore, and bulk replacement paths take the same row lock; Delete All takes the barrier exclusively. The checked authority therefore cannot be replaced between that preflight and the identity-bearing discovery request. Another tab fails before overlapping discovery, while a replacement waits for the existing Edit owner; a stale dialog also fails before it can touch the remote session. PWA fails closed when Web Locks are unavailable, while Electron relies on its single-instance local owner. Lazy repair tries the same barrier and row reservation before its persisted-source preflight; contention or unavailable PWA locking declines repair without discovery, while an acquired reservation stays held through its conditional row transform. The reservation blocks every new authentication (including a URL edit with the same normalized fingerprint) and repair, and drains any work already in flight. When Save follows a lazy repair in the same tab, Edit publishes its local authentication owner first, drains that repair through the actual Web Lock request completion, and only then requests the row reservation itself. Repair callers already queued behind that owner observe the Edit block and return without trying to reserve the row again. Ownership is rechecked after every asynchronous drain or authority rebase. Ordinary failure releases that reservation with the previous runtime untouched; if a bounded discovery result still has an abandoned authentication on the wire, Edit keeps both reservations until the transport operation actually settles. Success atomically replaces the endpoint, mode and normalized identity together with session metadata: simple mode clears token/fingerprint/watchdog/account state, while full mode replaces it with the confirmed authorization result. That awaited write returns the complete merged playlist row before NgRx receives its state-only update and before the app adapter replaces the active StalkerStore snapshot and session/watchdog state, so persistence failure cannot expose a partial runtime edit and metadata absent from the form (such as playback Referer/Origin) survives the replacement. The state-only update reattaches the transient session patch from discovery, because the persisted flat row deliberately does not contain that field; this lets NgRx replace or clear its session projection. Runtime configuration authority combines the session fingerprint with the observed full/simple mode. Both authenticated calls and direct simple-mode requests cross that guard before dispatch and again after transport, so a same-endpoint mode-only Edit rejects stale playlist objects in either direction before they can authenticate or issue a token-free portal request, and discards an older portal response that completes after Edit commits. A different authority may rebase only after the current persisted row proves that it owns the same playlist ID; this keeps delete/restore and backup merge flows usable without letting an in-flight stale request overrule Edit. PlaylistMetaUpdate carries the persisted part as a transient stalkerSessionPatch (undefined preserves, null clears, an object fully replaces); PlaylistsService projects it onto the existing flat playlist fields, so the patch itself never enters SQLite, IndexedDB or a backup and no schema or backup-version migration is required.

The shared playlist UI exposes only STALKER_PLAYLIST_CONNECTION_EDITOR and its resolved | auth-rejected | unreachable result contract. The web composition layer implements the token with Stalker data-access; the UI library must not import Stalker discovery directly.

Lazy repair (existing playlists). The flag is frozen in the DB, so records persisted by the old guess stay broken without repair — but a large share of users are on working reseller panels, and only probing can tell the two apart, so there is deliberately no eager one-shot migration. Instead StalkerPortalRepairService re-probes a portal only after a request actually failed with a shape that a wrong endpoint/mode produces (the plain-text auth bodies AND their JSON envelopes (js.error/js.msg), HTTP 404 (endpoint absent), HTTP 401/403 (endpoint behind an HTTP auth gate), and terminal handshake/profile errors — never timeouts or other network failures), at most once per SOURCE CONFIGURATION (endpoint + mode + MAC + identity fingerprint + credentials) per playlist per session — an edited configuration may probe when it fails, while every already-probed one stays latched for the session. Before an unrecorded probe makes any portal request, it verifies that the persisted row still carries the failing source; a request that failed late after Edit committed is declined before discovery can authenticate against the old portal and invalidate the edited session. A repair installs a per-playlist authentication fence before probing, drains authentication that already owns the runtime token slot, and makes request routing wait before it chooses the effective connection. The fence normally ends with the repair; an abandoned authentication keeps both it and pendingRepairs alive until the transport settles. Repair persists only a configuration discovery has proven to answer, and only when it differs from the failing one. A repaired configuration is applied immediately via an in-session override inside executeStalkerRequest() (stale store snapshots keep working). The override is bound to that source's endpoint, mode, device identity, and credentials; an Edit or backup restore under the same playlist ID retires it when any connection field differs, but only after the persisted row confirms ownership and only if no explicit Edit took ownership during that read — a delayed stale request cannot globally remove the current override or a token negotiated by the overlapping Edit. The repair is persisted through PlaylistsService.transformPlaylistMeta — the verification and the patch run in ONE slot of the per-playlist write queue, so a user edit that is queued but not yet committed wins over the repair instead of being overwritten; the transform patches the freshly read row (portalUrl + isFullStalkerPortal only, so user state can never be clobbered) and returns null to abort. Explicit Edit also advances an in-run generation before replacing the session. Repair captures it before any history-path row read and rechecks it together with the active Edit fence before reserving discovery, so restoring a discarded configuration cannot start a probe alongside Edit; a repair that already verified its row but finishes later likewise cannot install its older override or token. Deletion runs through the same queue, so a repair can never resurrect a playlist deleted mid-probe. Portals that work are never probed, let alone rewritten. E2E coverage: apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts against the mock's tolerant /portal.php, strict /server/load.php, and portal.php-less /ministra/* hosts.

Main UI Components

  • CategoryContentViewComponent from @iptvnator/portal/catalog/feature (libs/portal/catalog/feature)
    • Shared category + content layout used by the vod and series routes (wired in stalker-feature.routes.ts via loadCategoryContentViewComponent)
  • libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
    • ITV live playback, radio playback, channel/station navigation, EPG panel integration
  • libs/ui/playback/src/lib/audio-player/audio-player.component.ts
    • Shared inline audio player used by M3U radio channels and Stalker radio stations
  • libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts
    • Season/episode UI for all Stalker series modes
  • libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts
    • Favorites and recently-viewed collection views (mode = 'favorites' | 'recent' route data), rendering stalker-collection-detail.component.ts
  • libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts

Store and Data Flow

Stalker store is now feature-composed:

  • Facade: libs/portal/stalker/data-access/src/lib/stalker.store.ts
  • Feature slices: libs/portal/stalker/data-access/src/lib/stores/features/*
  • Shared helpers: libs/portal/stalker/data-access/src/lib/*

Important store responsibilities:

  • Selected content/category/item state
  • Category and content resources. VOD/series content is an infinite-scroll append: portal pages (server-side size, typically 14) accumulate into one deduplicated paginatedContent list; page 1 replaces it, hasMoreContent derives from the accumulated length versus total_items (so a portal that ignores requested page sizes still terminates), and a failed page > 1 sets appendError while keeping the accumulated pages on screen — retryContentPage() re-runs the same page via the resource's reload(). The facade splits the resource's loading flag by page: page 0 is the grid skeleton, later pages are the tail spinner, and loadMore() refuses to advance past an unresolved append error (a skipped page would leave a silent hole in the list).
  • ITV channel list + pagination (full-list session cache when the portal supports it, legacy 14-per-page lazy loading otherwise)
  • Radio category/station list + pagination
  • Regular series seasons resource
  • VOD-series (is_series=1) seasons + episodes resources
  • Playback link creation (create_link flow)
  • Favorites and recently viewed persistence helpers

Internal structure to preserve:

  • stalker.store.ts stays as the thin facade that composes feature slices.
  • Cross-slice contracts live in stores/stalker-store.contracts.ts so feature dependencies are declared instead of repeated unknown casts.
  • Request execution is centralized in stores/utils/stalker-request.utils.ts for both authenticated full-portal calls and simple IPC-backed requests.
  • Playback link resolution and Stalker collection persistence live in dedicated stores/utils/ helpers so player/favorites/recent slices stay focused on orchestration.
  • Category/content resources stay internal to the store slices. Feature consumers should read getCategoryResource() and getPaginatedContent(), which now always return arrays, and pair them with isCategoryResourceFailed() / isPaginatedContentFailed() for explicit error handling.

Category arrays belong to categoryPlaylistKey, not just the content type. Every category reader checks that owner; a portal switch clears all four section caches before loading the destination. Aborted or foreign-portal responses (including errors and radio fallbacks) cannot write into the active cache. resetCategories() only clears: the route session calls it on a portal switch before the destination is resolved and on teardown, where a request would go to the portal being left. When a handoff had already put the destination in the store, the owner does not change and the resource would not reload by itself, so the session calls reloadCategories() once it has installed that playlist. This contract applies to collection details as well as routed catalogs, because both use the root Stalker store.

Failure-handling rule:

  • Failed category or content requests must degrade into empty/error UI state, not undefined collections or renderer exceptions. The workspace Stalker context panel and live layout rely on this guarantee.

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 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.

  • The legacy default serial value BEDACD4569BAF is treated as absent at runtime so older blank imports do not keep sending a synthetic serial number.

  • Playback headers use the same serial normalization, so the legacy default is not sent as SN or as a serial-derived __cfduid. MAC-only API and playback requests do not synthesize __cfduid; when a real serial is present, same-origin playback uses a canonical 32-character __cfduid protocol cookie.

  • Stalker workspace routes must initialize StalkerStore from a playlist object with an explicit isFullStalkerPortal mode. If the active route metadata is a lightweight playlist record without that field, the route session must load the full playlist by id before category/content resources run. Stalker auth metadata is independent from M3U playlist EPG metadata and must not depend on M3U-specific EPG fields.

MAC address normalization

The MAC is canonicalized to the uppercase colon form a real STB sends (normalizeStalkerMacAddress in libs/shared/interfaces/src/lib/stalker-mac-address.util.ts), accepting hyphens, dots, embedded whitespace or no separator at all. The same module exports validateStalkerMacAddressControl, used as a form validator by the import dialog and the playlist-info edit dialog; it is typed structurally ({ value: unknown }) rather than as Angular's ValidatorFn, because this library is the contract layer the Electron main process imports and must stay framework-free.

Normalization runs only at the input boundary — on blur and again on submit, in both dialogs. The submit pass is not redundant: clicking Add or pressing Enter inside the field submits without the field necessarily losing focus, so the blur handler never runs and the raw 00-1a-79-… would be the value that gets persisted and sent. Stored MAC addresses are deliberately never rewritten on read:

  • the MAC is the account key, and the bytes an already-working playlist puts in its mac cookie are the bytes that portal accepted;
  • rewriting them at the transport would move stalkerSessionFingerprint for every existing playlist at once, with no user action and no way to opt out.

An edit therefore does move the fingerprint and force a re-authentication. That is intended: the user changed the identity, and the field shows exactly what will be sent.

Format validity is enforced; the Infomir OUI is advisory only. The stock server's MAC filter (enable_mac_format_validation) is on by default and only accepts 00:1A:79:XX:XX:XX, answering a bare {status: 1} for anything else — which is why hasInfomirMacOui drives a hint on the import field. It must stay a hint: reseller panels, which is what most users actually run, do not check the format at all, so a large share of working installations use a non-Infomir MAC. Refusing one would stop those users adding or editing a portal that works for them. The mock encodes the same split (enforceMacFormat is set only on the strict endpoint; /portal.php ignores it), and AUTH_REJECTED_MAC in stalker-portal.fixture.ts (used by stalker.e2e.ts) depends on it — a non-Infomir MAC that must reach the strict endpoint and be refused there, not in the form.

In the edit dialog both passes — blur and submit — normalize only a MAC the user actually changed, compared against the value the dialog was opened with (isStalkerMacAddressEdited). Renaming a playlist, or editing its EPG sources, must not rewrite a non-canonical MAC as a side effect: those bytes are what a permissive portal registered, and rewriting them would move the session fingerprint and re-authenticate under a spelling the portal never saw — the same reason a stored MAC is not rewritten on load.

Guarding the submit pass alone is not enough, and this is the subtle part: tabbing through the dialog fires blur with no edit, and a blur that rewrites the control both marks the form dirty and makes the value differ from the stored one — which is precisely what the submit guard reads. The identity would then ride out on a title-only save.

The edit dialog goes one step further: createStalkerMacAddressValidator grandfathers the value a playlist already stored. Before there was any validation the field accepted arbitrary text, and on a panel that ignores the MAC such a playlist works today — marking the form invalid on open would disable Save and strand the user's title, URL and EPG-source edits in the same dialog. Newly typed values are still held to the format.

Deriving device IDs from the MAC

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.

The two values must differ. On a real box they come from separate firmware calls (gSTB.GetUID() and gSTB.GetUID('device_id', token)) and are never equal, so an identical pair is a fingerprint no STB produces — and since the first non-empty value is pinned to the MAC permanently, it cannot be corrected afterwards. They are derived by one function returning both, so nothing can fill one without the other.

The shape of that feature is dictated by the pinning semantics: the stock server binds the first non-empty device_id/device_id2 it sees to the MAC permanently, refuses a different one as a device conflict, and treats a later empty value as a permanent lockout. Therefore:

  • the derived value is written into the visible form fields and persisted as a literal string. Nothing recomputes it at request time, where a MAC edit would silently re-derive it into a conflict;

  • the checkbox is offered at import only — the point where the identity is being established for the first time — and is disabled when the user has typed a device ID by hand;

  • while it is ticked, correcting the MAC re-derives, because nothing is pinned until the import actually runs;

  • derivation is asynchronous, and every way out of it is guarded. applyDerivedDeviceIds can only read the toggle before it awaits, so three things protect what happens after:

    • submitting re-runs settleMacAddressIdentity() and awaits it before reading the form — clicking Add blurs the MAC field, so the blur's SHA256 is still in flight when the click handler runs, and a snapshot taken then pairs the corrected MAC with the previous MAC's IDs;
    • a generation stamp discards every completion but the newest, so two edits in quick succession cannot leave the older pair in the fields;
    • invalidatePendingDerivation() bumps that stamp whenever the user stops wanting derived IDs — unticking the box, clearing the form — because an outstanding digest would otherwise resolve into fields that were deliberately emptied, and the import would pin IDs the user opted out of.

    All three are mutation-verified. Note the tests have to control when the digest settles (hold it behind a gate, or delay the older invocation): Node resolves digests this small in start order, so the naive versions pass with the guards removed;

  • the MAC and its device IDs travel as one value. settleMacAddressIdentity() reads the MAC once, before it awaits, and returns it together with the IDs derived from exactly it; addPlaylist() uses that triple rather than re-reading the form. Taking the MAC from getRawValue() after the digest would ship a newly typed address paired with the previous one's IDs, which the portal pins as a permanent device conflict. The whole form is also frozen (form.disable()) for the duration of an import and restored in finally, since an edit made then can neither reach the portal nor be undone on it;

  • the snapshot addPlaylist() takes is authoritative for the whole import, by design. Discovery authenticates with exactly those values, and get_profile is what pins them to the MAC — so by the time a slow discovery answers, the portal has already committed. Re-reading the form afterwards to pick up a mid-flight edit would persist device IDs that differ from the pinned ones, or none at all, and sending nothing after a value was pinned is the permanent lockout. The identity toggle is therefore locked while isLoading() rather than the submission being re-snapshotted: the UI must not imply an opt-out that cannot exist;

  • an unusable MAC (or a runtime without WebCrypto) derives nothing rather than hashing a typo into a permanent binding;

  • the playlist-info edit dialog offers no derivation. It shows DEVICE_ID_PINNED_WARNING instead once a device ID has actually reached the portal. Storage is not transmission: device_id travels only on get_profile/do_auth, which a simple panel-style portal never runs, and the import's offline fallback persists whatever was typed while recording the playlist as simple. hasStoredStalkerDeviceIds therefore gates on isFullStalkerPortal — telling those users a change "will lock this source out" would be false and would discourage them from fixing an ID that was never pinned.

    Known imprecision, deliberately not closed here. The gate proves that some device ID reached the portal, not that the currently stored one did: a user who edits the ID after import keeps isFullStalkerPortal true while the new value has never seen get_profile. The copy is hedged for exactly that ("a device ID", not "this one") and the fields stay editable, so the remedy — restoring the ID that was pinned — is never blocked. Making it exact needs a per-identity confirmation signal; Playlist.stalkerSessionIdentity already carries a session fingerprint that would serve, but reading it here would make a type:ui playlist library depend on the Stalker data-access lib, which the Nx boundaries forbid. Worth revisiting behind a shared contract, not worth a boundary exception.

The trade-off the option exists for is interoperability, not obfuscation: a user who reaches the same account from StbEmu already has this exact value pinned, and IPTVnator has to send it or be refused.

Reported device profile

get_profile carries a fixed MAG250 description from STALKER_STB_PROFILE_PARAMS (libs/shared/interfaces/src/lib/stalker-stb-profile.const.ts): ver, stb_type (MAG250, previously sent as an empty string), hw_version, image_version, client_type, plus the num_banks/video_out/hd the request already carried. The stock middleware stores these for the admin panel and only an operator's optional access_filter.php inspects them, so they are free to send — but they must describe the same box as metrics.model and the STALKER_MAG_USER_AGENT header, or the profile reads as a forgery.

They are constants, identical for every playlist, and deliberately excluded from stalkerIdentityFingerprint / stalkerSessionFingerprint: including them would invalidate every persisted session for no gain, since the portal binds nothing to them.

Session Authentication Lifecycle

Full portals authenticate through StalkerSessionService (libs/portal/stalker/data-access/src/lib/stalker-session.service.ts), which 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.
  • stalker-watchdog.controller.ts — the periodic get_events keep-alive.
  • stalker-token-cache.ts — the in-run token and pending-auth state, tagged with the identity fingerprint each session was negotiated for.
  • stalker-session-store.ts — the session persisted on the playlist row.
  • stalker-response-classification.ts + stalker-portal-error.ts — failure detection and the typed StalkerPortalError the UI layers render.

Handshake and token persistence

The handshake token is idempotent (Stalker 4.9.35 stb.class.php): re-presenting the MAC's current session token returns it unchanged, and tokens have no TTL — they are only invalidated when another device runs get_profile on the same MAC. ensureToken() exploits this: when the in-memory cache is cold it re-presents the persisted Playlist.stalkerToken (from the playlist object, falling back to the stored row via PlaylistsService, since store metas do not carry payload fields). A token that comes back unchanged is an already-adopted session, so the get_profile round trip is skipped entirely — unless the handshake also set not_valid, which vetoes the shortcut: adopting a token the portal just called dead would be unrecoverable, since the reuse path writes no replacement back and every later start would re-present it. A renegotiated session is written back best-effort (PlaylistsService.updateStalkerSession) so the next app start can reuse it. The handshake's not_valid flag is propagated into the follow-up get_profile as not_valid_token.

Reuse is gated on a session fingerprint (stalkerSessionFingerprint) covering the portal endpoint (origin, path, and URL Basic-auth userinfo), the device identity and the account credentials, stored next to the token as Playlist.stalkerSessionIdentity and used for the in-run cache as well, so an edit applies without a restart. All three halves are load-bearing: ensureToken() re-presents tokens in a handshake, so an endpoint edit would otherwise disclose the previous portal's bearer token to another portal — and origin alone is not enough, since discovery deliberately preserves tenant base paths, so /tenant-a/… and /tenant-b/… on one host are different portals. The URL parser omits user:pass@ from origin, so that userinfo is fingerprinted separately; changing a reverse proxy's Basic-auth identity must not reuse a bearer token negotiated through the previous one. Endpoints without userinfo retain their previous fingerprint across upgrades. An identity edit must not inherit the old session; and for a status-2 portal the login decides which account the token represents. A token with no recorded fingerprint (written before this existed) counts as unverified and is never re-presented — such a row owes a full profile anyway, and the write-back then records the fingerprint.

The Edit coordinator deliberately keeps a separate, in-run configuration authority key containing that session fingerprint plus the observed portal mode. The mode is not added to stalkerSessionIdentity, so existing persisted sessions remain compatible, but a full↔simple Edit at the same endpoint still retires stale runtime snapshots. The token-free dispatch path calls ensureToken() as a network-free authority guard before its direct IPC request.

Because that reuse skips the only response carrying the watchdog cadence, the cadence is persisted with the token (Playlist.stalkerWatchdogTimeout / stalkerTimeslot, payload fields like stalkerToken itself — no schema change) and re-applied on the reuse path. The import dialog persists what its own get_profile advertised, which for a portal whose token never goes stale is the only profile the app ever sees.

The skip is therefore conditional on the cadence being known: a playlist imported before the cadence was persisted has a reusable token and no cadence, and skipping would strand it on the 120 s default permanently, since the profile is the only thing that could teach it. Such a playlist runs one profile, persists what it learns, and skips from then on. What gets persisted is the effective cadence (the 120 s default when the portal advertises none), so stored absence keeps meaning exactly one thing — never profiled — rather than sending a portal that advertises nothing back through a profile on every start.

get_profile status decoding

authenticate() decodes js.status the way the stock middleware means it:

  • full profile / status: 0 — OK; watchdog_timeout and timeslot are read for the watchdog cadence.
  • status: 1 — refused (device conflict, malformed MAC, disabled account). 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. 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 second auth step — the initial request sends auth_second_step=0. Missing credentials throw StalkerPortalError('login-required'); a {js: false} verdict (the operator billing script refused) throws 'login-rejected'.

Credentials come from the import dialog's username/password fields and are persisted on the playlist, so runtime re-authentication can repeat do_auth after the portal drops the session.

Plain-text failure bodies

Auth failures are HTTP 200 + a text/html body, never a 401/403. The three exact bodies (Authorization failed. — stale/missing token, optionally with a numeric debug suffix; Access denied. — blocked account; Unauthorized request. — missing mac cookie) are classified at the transport boundary: the Electron main process (apps/electron-backend/src/app/events/stalker.events.ts) converts them into a structured { stalkerAuthFailure } marker (libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts) — returned, not thrown, because ipcRenderer.invoke strips custom properties from rejections. The PWA proxy path still delivers the raw string; the renderer classifier accepts both shapes plus the legacy { js: '<body>' } envelope. makeAuthenticatedRequest retries once with fresh authentication and otherwise throws StalkerPortalError('auth-failed') carrying the body.

Error surfacing

StalkerPortalError.portalText holds the portal's own words. The import dialog shows them inline under the portal URL, in the same role="status" paragraph the Xtream form uses for its connection test, after a kind-specific i18n headline (HOME.STALKER_PORTAL.*, mapped by toStalkerImportFeedback). The template translates both parts, and any edit clears the message. Outcomes that close the dialog (validated with an expiry date, or added without validation) use translated snackbars instead. The workspace context panel replaces the generic "could not load categories" hint with the portal text (or the login-required guidance) when category loading failed with a portal refusal (stalkerCategoryErrorDescription in workspace-context-panel.component.ts).

Both renderers are kind-agnostic — they append portalText whenever it is present — so the obligation sits entirely on the throw sites: every exit out of the status-2 branch carries the text, not just the terminal blocked one. A login refusal is precisely where the portal says something actionable ("wrong password", "subscription expired"), and dropping it leaves the user with a generic line while the useful sentence sits unread in the payload. Each exit reads the response IN HAND: after the auth_second_step=1 retry the text is the retry's, since quoting the first profile back would describe a request that already succeeded. do_auth itself answers a bare {js: false}, so a rejection there keeps the profile's text — the one that asked for the login.

Device conflicts

A device conflict is the one refusal with a concrete remedy, and the one where relaying the portal verbatim actively misleads: the stock server answers {status: 1, msg: "device conflict — device_id mismatch", block_msg: "Your STB is damaged…"}, and hardware failure is not what happened. It therefore gets its own StalkerPortalErrorKind and its own headline — the import dialog maps it to HOME.STALKER_PORTAL.DEVICE_CONFLICT, and the workspace context panel is the single place that overrides its otherwise kind-agnostic "portal text wins" rule: PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT leads, the portal's own sentence follows.

isStalkerDeviceConflictMessage (stalker-portal-error.ts) matches a small phrase set against msg/block_msg. Two boundaries are deliberate: it reads a STRUCTURED field the middleware wrote, so a phrase set is safe here in a way it would not be against a raw HTML body (the asymmetry documented for isStalkerAuthFailureBody); and it stays narrow around the binding itself, because "device limit reached" or "no device selected" are different refusals and offering "restore your first device ID" for them is a dead end.

Abandoning an authentication

authenticate() takes an optional AbortSignal and checks it before every portal call. Endpoint discovery gives each confirmation attempt its own controller and aborts it when the attempt exceeds its budget, before moving to the next candidate.

This matters because get_profile — not the handshake — is what adopts a token for the MAC portal-side. Without the check, a timed-out attempt could still send its get_profile after a later candidate had authenticated, invalidating that healthy candidate's token and making discovery report a working portal as refused.

Cancellation is deliberately cooperative rather than a socket-level abort threaded to axios: once a request is on the wire the server processes it regardless of what the client does, so tearing the socket down would not prevent the adoption. Only not sending the request does, which is exactly what the between-calls check guarantees.

That leaves the window where the timer fires while a get_profile is already dispatched — which the check cannot cover, but sequencing can: discovery drains the abandoned attempt (bounded by one request budget) before probing the next candidate, instead of racing it. The request cannot be un-sent, but nothing forces us to have a competing session in flight while it lands.

A drain that times out stops discovery. Draining is bounded, and the bound has to mean something: an attempt still unsettled after its own 65 s budget plus the 15 s drain is one no transport can recall — the PWA fetch() takes no signal at all, and the Electron main process runs its HTTP request to completion. Advancing anyway would stake the next candidate's freshly issued session on that request never landing. So the rejection carries abandonedInFlight plus a settlement promise and the candidate loop returns instead of probing on, preferring an honest "could not confirm this portal" the user can retry over a session that looks established and dies later. It costs nothing in the normal case: an aborted attempt settles as soon as its in-flight request errors out, so the drain returns at once and the loop continues. Edit may return that bounded error to the dialog, but its session and repair fences remain installed until the settlement promise resolves. Import reports the refusal immediately but keeps Add and the form disabled for the same lifetime. Lazy repair retains its pending/runtime-authentication fences. Catalog, watchdog, repair, Add and retry authentication therefore cannot race the abandoned get_profile.

The budget itself covers the longest real flow: a status-2 portal costs four sequential requests (handshake, profile, do_auth, profile retry) and the Electron transport allows each 15 s, so a two-request budget would have failed valid but slow login portals.

Watchdog

The portal expects get_events every watchdog_timeout seconds — 120 by default, echoed in the profile together with a per-user timeslot jitter that offsets the first periodic ping. StalkerWatchdogController starts with an immediate init=1 ping on activation, applies the profile cadence when a profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses the documented 120 s default. Failing to ping never invalidates the session — it only affects the portal's admin-panel "online" reporting — so ping failures are logged and never retried or escalated.

The periodic ping ticks in a dedicated worker (createBackgroundInterval, an inline blob worker allowed by the renderer CSP's worker-src 'self' blob:). After five minutes hidden and silent, Chromium wakes page timers at most once per minute, which would halve a 30 s cadence while the window is minimized; worker timers are not subject to that page throttling. Where no worker can start, the controller falls back to a page setInterval.

Request Transport and cmd Encoding

Requests to an unreachable portal are short-circuited by the main process' host connectivity guard rather than hanging their full 15/30 s timeout again. That guard's refusal is classified by the same message-text rules discovery uses (it lands in the "connection-level failure" slot below), and endpoint-discovery probes are exempt from it via skipConnectionGuard — see host-connectivity-guard.md.

A real MAG/STB sends cmd unencoded: the portal's client JS concatenates raw key=value pairs, the browser URL layer escapes only what a URL cannot carry, and PHP's $_GET applies exactly one form-urldecode. The portal therefore sees the stored cmd decoded once — a pre-encoded %3A arrives as : and a literal + arrives as a space. IPTVnator reproduces that reference wire format on both transports with the shared encodeStalkerCmdValue() (libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts):

  • % passes through untouched, so a cmd that already contains percent sequences is never double-encoded (the pre-0.23 encodeURIComponent transport delivered %253A and strict panels no longer matched the string).
  • Characters the WHATWG URL serializer keeps raw in a query stay raw (/ : ? = + , @ $ [ ] …), so the emitted bytes survive the axios/new URL transport unchanged.
  • Everything else is percent-encoded. This keeps the injection protection from the 0.22 hardening: &, # (and ; for PHP setups with a ; argument separator) inside cmd cannot append or truncate query parameters — they decode back to the original byte server-side, so the portal-visible value is unaffected.

Both transports assemble the portal request from the same two shared builders in @iptvnator/shared/interfaces, so their wire format cannot drift apart:

  • buildStalkerRequestUrl() (libs/shared/interfaces/src/lib/stalker-request-url.util.ts) builds the full portal URL: cmd uses the reference encoding, every other param stays fully encodeURIComponent-encoded, JsHttpRequest=1-xml is appended when missing, and any query carried by the portal URL itself is dropped.
  • buildStalkerIdentityRequestContext() (libs/shared/interfaces/src/lib/stalker-request-identity.util.ts) builds the STB identity: the mac/stb_lang/timezone cookie (plus a serial-derived __cfduid), the MAG User-Agent/X-User-Agent pair (STALKER_MAG_USER_AGENT), Accept/Accept-Language/Connection, the SN header and Authorization: Bearer when present, and the serial parameter rule: sn travels only on get_profile (injected there, stripped everywhere else, mirrored into the metrics JSON).

Consumers:

  • Electron: the STALKER_REQUEST handler (apps/electron-backend/src/app/events/stalker.events.ts) feeds both builders directly.
  • PWA: the renderer (PwaService.forwardStalkerRequest) sends macAddress, token, and serialNumber as control params on the renderer→proxy leg (URLSearchParams, which Express decodes losslessly). The web-backend /stalker proxy consumes them into the identity headers via the same shared builders and never forwards them in the portal's query string — portal credentials must not land in portal or intermediary access logs. The one protocol exception is handshake, whose candidate token is genuine query content (the portal reads it for the idempotent-handshake path) and is re-injected there.
  • Mock: the stalker-mock-server's /stalker route (apps/stalker-mock-server/src/main.ts) mirrors the proxy with the same shared identity builder, so PWA E2E runs exercise the real contract (including query_keys_received diagnostics matching what a real portal would log).

The mock portal's create_link response carries mock-only cmd_received and query_keys_received diagnostics so E2E can pin this contract (apps/electron-backend-e2e/src/providers.e2e.ts).

Response-side cmd normalization is also shared: both the Stalker store and the cross-portal collection resolver (StreamResolverService) use normalizeStalkerPlaybackCommand() / resolveStalkerPlaybackUrl() from libs/portal/stalker/data-access, which strip the <solution> prefix and resolve relative (/media/...) or query-only (?token=...) create_link replies against the portal base URL.

A Stalker catalog row decides for itself whether it needs a temporary link. The portal's own player.js — mirrored by Kodi's pvr.stalker — calls create_link only when the row sets use_http_tmp_link (the portal proxies the stream through a per-session URL) or use_load_balancing (the portal picks a storage server per request). Every other row plays the static cmd that get_all_channels / get_ordered_list already returned. Until PR 8 the app called create_link unconditionally, so every playback paid a round trip and gained a failure point that the reference client does not have.

One helper owns the decision: resolveStalkerStaticPlaybackUrl(row, cmd) in libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts. It returns the playable URL when the static path applies and null when the portal has to resolve the command. null is deliberately wider than the flag check alone; the extra guards can only push a row back onto the create_link path, so they cannot regress a portal that works today:

Input Verdict Why
Either flag truthy (1, '1', true) create_link The portal asked for a temporary link.
No row supplied at all create_link A caller that cannot show the flags gets no verdict.
A row carrying neither flag KEY create_link Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by buildStalkerSelectedVodItem's whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. Radio is the documented exception: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting.
Relative /media/file_12.mpg or query-only ?token=… create_link Only the portal turns those into an address; the VOD has_files rewrite produces exactly the first shape.
Non-HTTP scheme (ffrt4://ch/live/…) create_link Portal-internal pseudo-URL.
Portal-local host — localhost and any *.localhost name (RFC 6761 §6.3 reserves the whole suffix for loopback), localhost.localdomain, all of 127.0.0.0/8, 0.0.0.0, ::1, ::, and the IPv4-mapped forms URL normalizes to hex (::ffff:7f00:1); a terminal DNS root dot is stripped first create_link ffrt3 http://localhost/ch/1234_ is an instruction to the portal, not an address a set-top box could open.
Otherwise static cmd Solution prefix stripped by normalizeStalkerPlaybackCommand().

fetchStalkerPlaybackLink() applies the verdict for ITV, VOD and radio, and short-circuits before the request. It never applies it when series is set: an episode is selected server-side by that parameter, so the parent row's static cmd addresses the series, not the episode.

The flags must survive normalization. buildStalkerSelectedVodItem() (stalker-vod.utils.ts) narrows a raw portal row to a whitelist, so a field it does not name is silently dropped — and it feeds both VOD playback (selectedItem()) and the download payload (createStalkerVodItem passes its data straight through). Losing a flag there fails open: the row reads as "no temporary link needed" and the static path plays the portal's non-final URL. Both flags are therefore on the whitelist, on StalkerVodSource / StalkerSelectedVodItem, and pinned by tests in stalker-vod.utils.spec.ts. Any new normalizer between a portal response and a playback call has the same obligation.

Callers pass the row they resolved the cmd from:

  • ITV and radio — the channel/station row (withStalkerPlayer); radio previously bypassed create_link for any directly playable command, which meant a proxied station played a URL the portal never intended to serve.
  • VOD and series — selectedItem().
  • Downloads — the movie payload, through the optional linkFlags argument on fetchLinkToPlay(), but only for a credential-free URL (see below).
  • Favorites and Recently Viewed — StreamResolverService.resolveStalker() reads the flags off the persisted raw row (UnifiedCollectionItem.stalkerItem). Radio keeps its long-standing "directly usable command plays as-is" behaviour, keyed off flag EVIDENCE rather than snapshot presence — a radio row persisted before the flags were carried has a snapshot that simply lacks them, and testing presence would skip the fallback for exactly those rows. withStalkerPlayer's radio branch applies the identical rule; the two must not drift.

The static path still needs the session

create_link was also the request that warmed the portal session, and tokens live in memory only (StalkerSessionService.tokenCache). Skipping it therefore has to account for streams that are still gated on the Bearer token — and "which routes are already warm" is not a question worth answering per route: the global collection detail sets the playlist and the selected item straight from a persisted row, with no catalog load in between, so a VOD opened from Favorites reaches the store's playback path stone cold.

Every static return therefore warms first, through one primitive — ensureStalkerSession() in stalker-request.utils.ts, wrapping StalkerSessionService.ensureToken():

  • fetchStalkerPlaybackLink() calls it before returning a static URL, which covers ITV, VOD, radio and downloads at a single choke point.
  • StreamResolverService calls it on its own static branch, which does not go through that function.

ensureToken performs handshake + get_profile with no link minted, and validates the identity the cached token was negotiated for — which the raw getCachedToken() cannot. It is cheap where it is not needed: a simple portal runs only the in-memory configuration-authority guard and returns immediately, while a warm cache with a matching fingerprint resolves without a request.

The store's player feature reads getCachedToken() for its header set, which is safe there because it runs immediately after the warm above populated the cache for that same playlist. StreamResolverService cannot make that assumption — a direct-URL favorite reaches it with nothing warmed — so it goes through ensureToken instead; see "Playback Header Contract" below.

The classification happens BEFORE the handshake, not after: a foreign-host static URL never needs the session at all, and warming it anyway would stall playback behind a request worth up to 15 s against a portal that may be slow or offline while the CDN is perfectly reachable — for a result that is then discarded.

A foreign-host static URL is returned before the handshake is even attempted — it never needed the session. A portal-owned one with no usable session (handshake failed, or threw) would be served knowing it will 401, so both call sites fall back to create_link instead — which mints a URL carrying its own token and, crucially, is the only path that can observe a failure and trigger the lazy portal repair. That keeps a playlist still misclassified as token-free, or pointing at an unrepaired endpoint, on the self-healing path it was on before this change.

ensureStalkerSession returns that verdict: true for a portal that needs no token and for one holding a usable token, false for a full portal left without one. It swallows a throwing handshake rather than propagating it — an unreachable portal must not surface as an exception mid-playback — which simply makes the verdict false and routes the row to create_link.

Known trade-off: a cached token is not revalidated. ensureToken returns a same-identity cache entry without touching the network, so the static path no longer self-heals a token the server has retired — something create_link used to do for free, since makeAuthenticatedRequest retires and re-auths on an authorization failure. This is narrower than it sounds: per the 4.9.35 reference, handshake tokens have no TTL, and failing to send the watchdog does not invalidate auth (it only clears the admin panel's "online" status). The one real vector left is another device performing get_profile on the same MAC — common enough on shared subscriptions, but wherever a watchdog is running it still self-heals within a ping cycle, because the ping goes through makeAuthenticatedRequest too. What is left uncovered is a same-host static stream played while no watchdog is up.

Revalidating on every static playback would cost exactly the round trip this section exists to remove, so it is deliberately not done here. The right home for a fix is the auth lifecycle (PR 6): refresh on an observed playback authorization failure, rather than pre-emptively on every play.

Downloads are the exception, and cannot use this. A download request cannot carry portal credentials at all — the main-process stored-header allowlist is User-Agent / Origin / Referer only (download-request-headers.ts), with no Cookie or Authorization. So startStalkerVodDownload offers the static shortcut only for a URL that needs none: it classifies the candidate with isStalkerStreamCredentialSafe() and withholds the row (forcing create_link) for anything portal-owned. A same-host movie keeps using the minted URL, which carries its own access token; a CDN-hosted one keeps the permanent URL that survives retry.

A temporary link lives about 5 seconds (tv_tmp_link_ttl / vclub_tmp_link_ttl, both default 5). It is time-limited but not single-use, so the rule is to resolve immediately before playback and never persist, cache or replay the result. What each persisting path actually stores:

Path Stores Verdict
Recently Viewed the raw row including cmd (buildStalkerRecentlyViewedPayload spreads the item) Re-resolves on replay.
Favorites the raw row including cmd (addStalkerFavorite, toggleFavorite) Re-resolves on replay.
Playback positions playlist_id + content_xtream_id + content type only — no URL column Not applicable.
Main-process playback context (stalker-playback-context.service.ts) header sets keyed by the stream URL's origin + path, 15 min TTL Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare.
ITV full-list cache (StalkerItvCacheService) catalog rows Rows, not links.
Downloads the resolved url on the downloads row The one exception — see below.

The download row is the only place a resolved URL outlives the playback that produced it, because the main-process downloader needs a URL it can retry and resume with. Honouring the flags shrinks the exposure: an unflagged movie on a host that needs no portal credentials now yields a permanent URL that survives retry. A movie that needs a temporary link — or one on the portal host, which a download cannot authenticate against — still stores a link that is dead by the time retry runs. Fixing that needs the cmd on the download row plus a re-resolution step before retry/resume, which is a schema change and is deliberately out of scope here.

forced_storage and play_token

Both are deliberately unused, and neither appears in the 4.9.35 reference fact set as a parameter the stock server enforces:

  • forced_storage is a create_link request parameter that pins VOD playback to one storage server. It exists for clients that let the user pick a storage; IPTVnator has no such concept, and omitting the parameter is what produces the empty value the portal treats as "no preference". Wiring it would first need storage discovery and a picker in the VOD detail view.
  • play_token is a create_link response field for clients that assemble the stream URL themselves. IPTVnator plays the cmd the portal returns verbatim (after the solution-prefix strip and base resolution above), so the token the stream needs is already in the URL. Note the coupling with the section above: on the static path no create_link runs at all, so no play_token is ever produced — which is consistent, because a row that wants neither flag is announcing that its cmd needs no portal-minted credential.

Revisit both only with a portal that demonstrably fails without them.

Regression coverage

Writing tests against this section: the decision chain has several exits — no flag evidence, unresolvable command shape, series set, foreign host, session unusable — and more than one of them can satisfy the same assertion. Four tests in the PR that introduced this were found passing through an exit other than the one they named (a foreign-host command reaches neither the handshake nor create_link, so it silently stands in for "simple portal" or "handshake failed"). Mutation testing does not catch it: it proves a test is coupled to its target, not that it reached the mechanism in its name. Check the mock setup against the execution path, and assert the step you mean was actually taken — expect(ensureToken).toHaveBeenCalled() rather than only the returned URL.

  • stalker-link-semantics.utils.spec.ts — the decision table above.
  • stalker-vod.utils.spec.ts — both flags survive buildStalkerSelectedVodItem / normalizeStalkerVodDetailsItem / normalizeStalkerFavoriteItem, and an unflagged row gains no flags.
  • stalker-player-request.utils.spec.ts — static short-circuit, both flags, the series exception, relative VOD commands, the session warm-up (simple portal skipped, repaired endpoint used, failure degraded) and the portal-owned-without-session fallback to create_link.
  • with-stalker-player.feature.spec.ts — ITV/radio store paths and proof that Recently Viewed stores the cmd, never the stream URL.
  • stream-resolver.service.spec.ts — the collection route, plus the cold full-portal session warm-up and its best-effort degradation.
  • stalker-vod-download.spec.ts — a CDN movie takes the static shortcut, a same-host one keeps minting.
  • stalker-playback-context.service.spec.ts — headers only, query-insensitive key.
  • apps/web-e2e/src/stalker.e2e.ts — mock scenario 00:1A:79:00:00:0A serves unflagged ITV rows with a playable cmd; the spec asserts NO create_link request reaches the portal, and a companion test on the default (flagged) scenario proves the recorder does see one when a link is due.

Playback Header Contract

Every playback kind — ITV, VOD, series episodes, and radio — resolves its stream and attaches the same portal header set through buildStalkerExternalPlaybackHeaders() (libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts). The collection routes (Favorites/Recently Viewed) share the contract: StreamResolverService.resolveStalker() builds the identical profile for the 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 — 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:

  • Portal-owned (isStalkerStreamCredentialSafe() in @iptvnator/shared/interfaces): the stream host equals the portal host (compared with a terminal DNS root dot normalized away, since portal.example. and portal.example are the same host but URL keeps the dot) — including a different port or an http→https upgrade, the routine IPTV panel shape (#1158 class). These streams get the full MAG profile: Cookie (mac=… plus protocol cookies), Authorization: Bearer <token> when a session token exists, User-Agent (playlist override or the MAG UA — the API path always sent both, the playback set historically sent only X-User-Agent), X-User-Agent, SN when a real serial exists, and Origin/Referer set to the portal origin.
  • Foreign / direct (different host, or an https→http downgrade): the credential-free KSPlayer direct-stream profile (User-Agent: KSPlayer, Accept, Icy-MetaData, Connection). Portal credentials must never reach a third-party host; direct stream URLs carry their access token in the URL minted by create_link.

Neither playback profile sets Range; byte ranges belong to the media transport and must change with each seek. A static Range: bytes=0- overrides mpv's requested offset, making the server return the beginning again. After resuming a movie or episode this can send playback forward or to EOF instead of the selected time. Regression coverage in stalker-live-playback.utils.spec.ts checks both profiles and TLS downgrades; with-stalker-player.feature.spec.ts verifies the resumed CDN episode path.

The token is bound to the endpoint the headers claim. Both header inputs — the portal coordinates and the Bearer token — must describe the same portal, or a session negotiated for one host is presented to another. In StreamResolverService that is structural: the token is resolved from the same headerPlaylist object the headers are built from, never from the row it was derived from. The live case is a completed lazy repair, which moves the endpoint in the override but not in the stored row. Resolving from the override also keys the session cache the way ensureStalkerSession() and executeStalkerRequest() already do, so the collection route reuses their session instead of handshaking again for a second fingerprint.

For the same reason headerPlaylist is built by applying the override to the row rather than folding in the two resolved coordinates: a repair rewrites the portal mode as well as the URL, and the token resolver is mode-aware. A playlist repaired from simple to full would otherwise keep its stale isFullStalkerPortal: false here — the create_link request having already run under the repaired mode and adopted a token — and the same-host gated stream would go out with no Bearer header on the very playback the repair existed to rescue.

That resolution is skipped for a foreign host, whose profile carries no token anyway — obtaining one would only stall playback behind a handshake, exactly the trade the static branch avoids by classifying first.

The Electron main process keeps a fallback header context per resolved create_link URL (stalker-playback-context.service.ts) for external-player launches that arrive without renderer headers. It classifies streams with the same shared predicate — if the two ever diverged, isStalkerDirectStreamProfile in the external-player path would discard the renderer's credentialed headers for streams the main process misread as direct.

The fallback also leaves Range unset, covered by stalker-playback-context.service.spec.ts, so external launches cannot reintroduce a fixed byte offset when renderer headers are absent.

The mock server's gated-stream scenario (MAC 00:1A:79:00:00:09) makes create_link return a local /stream/gated/video.mp4 that answers 403 without the mac cookie and current Bearer token; apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts uses it to prove a built-in player's media requests really carry the credentials.

Live TV and Radio

The Stalker live route and radio route intentionally share StalkerLiveStreamLayoutComponent:

  • itv uses type=itv&action=get_ordered_list, stores results in itvChannels, resolves playback through resolveItvPlayback(...), and keeps the EPG panel visible.
  • ITV additionally loads the COMPLETE channel list once per portal session (see "Full ITV channel list cache" below), so category views and search are not limited to the lazily loaded 14-item pages.
  • radio uses type=radio&action=get_ordered_list, stores results in radioChannels, resolves playback through resolveRadioPlayback(...), and renders AudioPlayerComponent instead of a video player.
  • Radio hides the EPG panel and must not call Stalker EPG endpoints because radio stations do not have EPG data.
  • Radio always uses the inline audio player. External player settings are ignored for Stalker radio, matching M3U radio behavior.
  • Radio stations opened from favorites or recently viewed remain live collection items with radio: 'true'; the shared collection resolver uses create_link with type=radio, skips EPG loading, and renders the same AudioPlayerComponent layout instead of the Stalker VOD detail layout.
  • Some Stalker portals do not expose radio categories. Radio category loading falls back to a synthetic PORTALS.ALL_RADIO category with category_id: '*' so the station list can still be loaded.
  • A category click in the shell context panel only re-filters the channel sidebar; the selected channel or station keeps playing (Xtream live #936 and M3U group parity). onStalkerCategoryClicked therefore must NOT clearSelectedItem() for itv/radio — the layout gates its player on selectedItem — while VOD/series clicks still drop the open detail before navigating to the list route. The layout's category-change reset effect clears only list state (channels on the legacy paged flow, page, row EPG previews); the active channel's short-EPG fallback and a fallback load still in flight belong to the selection and survive the switch. Only a section change (itv ↔ radio, where the route session clears the selection) invalidates that request and drops the fallback. A playing channel outside the newly selected category can be revealed with Show playing channel in the channel header. Remote up/down, numeric selection and status retain the captured playback order while browsing. See the queue and reveal contract.
  • Arrival with openStalkerLiveItemId in history state (a collection's "open in playlist") is served by StalkerLiveAutoOpen (stalker-live-auto-open.ts): once the store serves the requested portal and the full ITV list (below) is ready, the channel is located there, its genre selected and playback started; see Portal Detail Navigation.

Full ITV Channel List Cache

Stalker portals paginate get_ordered_list with a server-side page size (typically 14 items), so lazy loading alone can never power a complete local search — this used to limit ITV search to whatever pages the user had scrolled through. StalkerItvCacheService (libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts) fixes this with a per-portal, in-memory session cache of the complete live channel list:

  • Load strategy: first try the Ministra get_all_channels action (type=itv, returns ALL channels in one response — the same call STB clients use); if the portal does not implement it, crawl get_ordered_list pages (category=*, genre=*, concurrency 4, one retry per page, early stop on an empty page or a page that adds no new channel ids — some portals ignore p and repeat — 30k-channel hard cap) with progress reporting. The assembled list is de-duplicated by channel id (both strategies) so it never collides with the template's track item.id. The loading strategy itself is a stateless helper (stalker-itv-channel-loader.ts); the service owns state.
  • Outcomes: a well-formed but unusable response marks the portal unsupported for the session (legacy paged flow stays in charge); a transient failure (network, or a page that failed both attempts) is retried later but throttled by a per-portal cooldown (ERROR_COOLDOWN_MS, 30s) so a deterministically-failing page can't trigger an unbounded re-crawl loop.
  • Per-portal reactivity: the "cache ready / refreshed" trigger is a per-portal version signal (versionFor(playlist)), not one global counter, and the content resource reads it only for ITV. This is load-bearing: a global counter re-fired the resource for whatever was on screen (radio, another portal), and the legacy paged branch appends at pageIndex > 1, so an unrelated load completing duplicated the visible page (colliding track item.id → NG0955). The isCurrentRequest guard is scoped the same way.
  • Integration: the getContentResource loader in with-stalker-content.feature.ts serves ITV categories from the cache when ready (local tv_genre_id filtering via filterItvChannelsByGenre, hasMoreChannels=false), and otherwise runs the legacy paged fetch while ensureLoaded() fills the cache in the background; the resource re-fires via the cacheVersion signal once the full list arrives.
  • UI: StalkerLiveStreamLayoutComponent windows the rendered list (100-item chunks extended by the existing scroll handler) so multi-thousand channel lists do not blow up the DOM; the header count and search cover the whole category; a refresh button re-loads the list in place; a progress line shows crawl status.
  • Loading state contract (important — regressions here strand the sidebar on a skeleton): in full-list mode the content loader serves the filtered list synchronously from the cache. The category-change reset effect therefore must NOT setItvChannels([]) while itvSelectedCategoryFromCache() is true — it runs after the store resource and would clobber the freshly served list, leaving every category after the first stuck on a skeleton. The initial-loading skeleton (isInitialChannelsLoading) must key off an actual in-flight load (itvFullListLoading() or isPaginatedContentLoading()), not merely an empty channel list; an empty result once loading has settled is an empty category and renders PORTALS.NO_CHANNELS_IN_CATEGORY, not a spinner.
  • Search (#1543): the sidebar and fullscreen fields independently filter the complete selected category, or the whole public catalog in All Items (both the uncategorized grid and *). Neither merges foreign cached genres into a category. ITV search never changes provider request parameters or resets accumulated pages: this keeps the fullscreen field independent of sidebar text. Cached categories are searched before the 100-row render window; uncached/censored categories continue get_ordered_list pages when a short/empty result cannot scroll, and continue on scroll otherwise. Search results use 100-row windows. Clearing or changing the query resets the render window, and category changes reset both list windows without restarting playback. Empty or repeated provider pages terminate pagination; a cache-ready replay of the same uncached page is deduplicated without hiding later pages. Aborted requests cannot overwrite the current category, even after navigating away and back. Radio stays on its separate station list and server-search flow. The workspace shell retains its loaded-only hint for ITV portals without a full cache and for radio.
  • Windowed selection: remote channel-up/down and numeric select operate over the full filtered category, so the render window (renderLimit) grows to include a selection beyond it (ensureChannelWithinRenderWindow) instead of drifting off-screen.
  • Category count badges: the context panel shows per-genre channel counts on Stalker Live TV categories (like Xtream/M3U), fed by the store computed itvCategoryItemCounts (the full list grouped by numeric tv_genre_id; the '*' "All" row's total is stored under the NaN key that Number('*') produces). Badges are ITV-only — VOD/series/radio still page lazily so their per-category totals are unknown — and show a loading shimmer while the full list is still loading (workspace-context-panel → stalkerShowCounts / stalkerCountDisplayMode).
  • Censored (adult) genres: portals typically EXCLUDE these channels from get_all_channels (sometimes without even flagging the genre censored in get_genres), so the cache legitimately has zero channels for them. The content loader therefore serves a genre from the cache only when the genre-filtered result is non-empty; otherwise it falls back to the legacy paged get_ordered_list fetch, which still returns those channels. The store computed itvSelectedCategoryFromCache is the single source of truth for this mode — the live layout keys windowing/infinite-scroll/loadMore and the category-change reset off it, NOT off itvFullListActive. Count badges: genres with no cached channels get NO map entry and the category view omits their badge (omitMissingCounts) instead of showing a misleading "0". The mock server ships a censored For adults ITV category (id 1099) to exercise this path.
  • Eager preload + all-channels view (Xtream parity): entering the Live TV section immediately starts the full-list load (preloadItvChannels(), fired from an effect in StalkerLiveStreamLayoutComponent — not from the first category click), so the count badges and the all-channels view are available right away. Before a category is selected, the main area shows StalkerItvAllItemsComponent — an infinite-scroll card grid of every channel in the portal (a purely client-side render window over the cached list; it must never touch the store's legacy page state, which would re-fire portal requests). Clicking a card runs the same playChannel flow as the sidebar. Portals without a usable full list keep the "select a category" placeholder.
  • Scope: ITV only. VOD/series append server pages on scroll and page their search portal-side; radio keeps legacy paging (station lists are small).
  • The stalker-mock-server implements get_all_channels and provides the legacy-pagination scenario MAC (00:1A:79:00:00:06) to exercise the crawl fallback.

VOD/Series Modes

Stalker has multiple real-world data shapes. The current implementation supports all three:

Within Stalker portal data access and feature code, isStalkerSeriesFlag() is the canonical predicate for is_series. normalizeStalkerSeriesFlag() delegates to it and produces the normalized positive marker true or undefined. The activity normalizer in libs/shared/interfaces keeps its dependency-neutral equivalent for dashboard records. Both accept the same closed set: boolean true, numeric 1, or string '1'. Unsupported values do not by themselves classify a VOD item as a series.

  1. Regular Series (/series):
  • Seasons come from API resource (serialSeasonsResource).
  • Episodes are derived from season payload.
  • This is the only mode that sets selectedSerialId, which is what drives serialSeasonsResource. It is set purely from selectedContentType === 'series' — the series detail branch renders <app-stalker-series-view /> with no vodWithSeries input, so the API resource is its only episode source and the fetch must never be gated on item shape.
  • Modes 2 and 3 below are always opened under the vod content type, which leaves the id unset — otherwise every VOD detail open would fire a get_ordered_list&type=series request whose result is discarded.
  1. VOD with Embedded series[]:
  • Item is opened under VOD, but already contains episodes.
  • StalkerSeriesViewComponent creates a pseudo-season and renders episodes directly.
  1. VOD with is_series=1 (Ministra plugin behavior):
  • Treated as series flow from VOD context.
  • Seasons are fetched lazily.
  • Episodes are fetched on season select.
  • The season resource depends on the VOD item id and series mode, so a TMDB metadata patch does not reload seasons or discard loaded episodes. Pending episode requests belong to the exact loading season VM; replies from an old selection cannot fill a replacement list with reused provider season ids.
  • A single-season item's explicit title marker (s02, season2, (2 сезон), etc., in name or o_name) supplies the displayed season number, quick-start code and episode/playback/download metadata, independently of UI language and TMDB availability. Regular and embedded VOD series apply the same rule. Multi-season items keep provider numbering. Lazy VOD retains the original season key/number separately for stable tracking IDs and compatible legacy progress; provider request ids remain unchanged.
  • The series quick-start CTA can load the first unloaded VOD-series season before playback. Unloaded seasons are considered unplayed in full season order, so an earlier unloaded season is not skipped just because a later season was loaded manually. If all currently loaded episodes are watched and more season metadata exists, quick start loads the next unloaded season instead of showing the completed state. After a lazy load, quick start is recomputed from the mapped episodes before playback so provider episode ordering cannot start the wrong episode.
  • For unloaded VOD-series seasons, the CTA target label is derived from season metadata and rendered as SxxE01 until episode details are loaded.
  • Lazy VOD-series episodes use scoped tracking IDs derived from the parent series ID, provider episode ID, season key, and episode number. The season key follows the mapping fallback (season_number, then name, then ID).
  • The previous season/episode hash remains available only as a compatibility alias in legacyTrackingId. The scoped ID is the in-memory episode key, and new playback positions always use it.
  • Quick-start actions preserve both their translation key and interpolation parameters when adapted for the Stalker CTA. Dropping labelParams exposes the raw {{episode}} placeholder.

Series inline playback behavior is shared across all three modes:

  • Episode downloads preserve those three origins separately through the persisted episode_identity_scope. Coordinate compatibility may reuse only a row with the same proven scope. A pre-scope coordinate row is ambiguous and blocks the action rather than binding regular, embedded, or lazy VOD content to another mode; an exact canonical episode id still wins.

  • StalkerSeriesViewComponent maps every mode into mappedSeasons() and uses two episode identities. A pending playback request keeps an exact, request-local provider command or episode ID so command rotation and hash collisions reject stale completion. Once mounted, the component freezes only the credential-free structural identity and session key (source, normalized parent, mode, season key, season number, and episode number).

  • The mounted structural identity is re-resolved against the current mappedSeasons() for metadata, Previous/Next, and autoplay. Same-owner provider and TMDB refreshes therefore expose current episode objects and commands without remounting the player; if the episode is missing or its structural coordinates are ambiguous, those surfaces and commands fail closed. Provider commands and IDs are never retained in mounted session state.

  • The inline player header shows the current episode metadata below the title, for example S01E03 - Episode title.

  • Embedded players receive previous/next episode state for the current season only.

  • Inline series autoplay is enabled by default. On player EOF (ended), Stalker starts the next episode only when it already exists in the current season's mapped episode list.

  • Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded is_series=1 season. Quick start remains the only flow that may load another VOD-series season before playback.

  • Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.

  • Before either inline or external playback starts, the resolved content info includes the parent seriesXtreamId and the mapped seasonNumber / episodeNumber. Future playback-position rows therefore carry enough metadata for workspace surfaces to render an episode badge. Existing rows without those fields are intentionally not migrated and remain badge-less until the episode is played again.

  • Ministra payloads may omit season_number. Episode mapping and lazy quick-start labels share the same naturally ordered season fallback so later seasons are not persisted as season 1.

Playback Position Identity and Compatibility

Legacy playback positions are reconciled lazily when the current parent series' positions and mapped episodes are available:

  • The lookup is scoped to the current parent series. A legacy row is eligible only when its stored season and episode metadata, when present, match the mapped episode.
  • An exact scoped tracking-ID row always wins. The old tracking ID may supply an in-memory compatibility alias only when no exact row exists.
  • On the next position write, IPTVnator persists the scoped row through the strict, failure-propagating persistence boundary before removing a confirmed legacy row. If the scoped write fails, the legacy row remains intact.
  • This is an on-read/on-write compatibility path, not a database schema migration or a bulk rewrite of saved positions.

The VOD-series contract is cross-surface:

  • Favorites and recently viewed records preserve the raw is_series flag and VOD origin so reopening still uses the lazy Ministra resources.
  • extractStalkerItemType() normalizes those activity records to dashboard type series.
  • The dashboard resolves episode progress by the parent seriesXtreamId and renders the saved season/episode metadata. It does not infer episode numbers from provider payloads.
  • Episode downloads from regular series, embedded VOD series[], and lazy Ministra VOD is_series=1 capture the rendered parent/episode metadata and provider category in a versioned offline snapshot. The focused Download Manager detail uses only locally available episode rows; it does not reuse the provider season resource as an offline availability list.
  • View in portal first looks for a matching recently-viewed Stalker snapshot. Candidates must match both identity and the requested movie/series mode, so overlapping provider ids cannot bind an episode to a movie or vice versa. When found, the handoff preserves the raw regular-series, embedded series[], or lazy is_series=1 shape; an exact numeric category from the download snapshot replaces a virtual vod/series collection category.
  • Without that compatible snapshot, only a movie with an exact persisted numeric category can form a metadata-only VOD target. Episodes and legacy movies without that proof leave the provider handoff unavailable rather than inventing a regular-series or generic VOD target. When a target does resolve, the provider host renders its content in identity-scoped provider-only presentation while Offline/local/download controls stay hidden.

Core decision logic and normalization are centralized in:

  • libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts
  • libs/portal/stalker/data-access/src/lib/models/*.ts

Forced External Launches

"Open in external player" needs a create_link round trip before it reaches MPV/VLC. The shared rules are in Forced External Launches From Detail Pages; the Stalker keys and queues are:

Series (StalkerSeriesViewComponent, stalker-series-launch-queue.ts):

  • Pending starts and the launch queue's held choices are keyed by playlist:series (currentSeriesKey). The view is reused across series and provider ids collide across playlists, so one series settling never drops what another holds.
  • A start is pending for its series from the click until it settles. A forced launch stays pending through the close of the previous player, the launch and the release of a held choice. The pending flag disables the hero button and the menu's external-player and watched rows.
  • Before launching, an episode of the same series still running externally is closed (replaceOwnedExternalSession). The request is rechecked after that close and after the launch IPC; a superseded launch closes the session it opened.
  • An episode chosen while a forced launch of its series is mid-flight is held (StalkerSeriesLaunchQueue.hold); the latest choice per series wins. On release it is dropped when the series is no longer shown. Otherwise replacePlayer closes what the launch opened before the choice starts, and an unconfirmed close drops the choice.
  • An episode chosen while a watched or reset batch runs is held in one slot tagged with its series; the last choice wins. When the batch settles it goes through the usual gates only if that series is still shown: episode identities overlap across series.

Movies (createStalkerVodDetailActions, used by the catalog detail, the collection detail and search):

  • A repeat for the same playlist:movie while its launch is in flight is ignored, also after leaving the movie and returning to it. Launches of other movies are not held back.
  • The launch joins the host's starts (beginPendingStart): it supersedes an earlier start, is dropped once a later one begins, and keeps Play, Start over, the watched toggle and the menu rows disabled until it settles.
  • The resolved stream is discarded when the movie is no longer selected or a newer start took over. Movie and series ids collide, so the catalog and collection details include the content type in the selection check; in search, a switch to a series changes the playback owner instead, which supersedes the launch. Otherwise the movie's own external session is replaced, the host's beforeExternalLaunch hook runs (the catalog and collection details close their inline player there), and the launch is sent. A launch that resolves after either condition changed closes the session it opened; one that fails by then is not reported.
  • "Reset progress" counts as a pending start of the movie until the write lands, so a start made meanwhile cannot resume from the row being cleared.
  • The pending start is owner-scoped (createPendingPlaybackStart). Each host retires it when the selection leaves the owner; that clears the pending flag, not the repeat guard of a launch still in flight.

Regression coverage: stalker-series-launch-queue.spec.ts, stalker-series-view.component.spec.ts, stalker-series-view.season-watch.spec.ts, stalker-vod-detail-actions.spec.ts, stalker-vod-playback-controller.spec.ts and, in libs/portal/shared/util/src/lib/, pending-playback-start.spec.ts and replace-owned-external-session.spec.ts.

Favorites and Recently Viewed

Current implementation is shared via Stalker-specific helpers:

  • createPortalCollectionResource(...) generic collection loader
  • createPortalFavoritesResource(...) favorites wrapper
  • createStalkerDetailViewState(...) unified "open detail" decision
  • toggleStalkerVodFavorite(...) shared add/remove behavior
  • normalizeStalkerEntityId(...) and normalizeStalkerEntityIdAsNumber(...) for stable ID matching
  • matchesFavoriteById(...) for cross-shape favorite matching

Where this is used:

  • libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts (favorites + recently viewed)
  • libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts
  • libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts
  • libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts

Navigation rule to preserve:

  • Stalker favorites, recently viewed, and search stay in their current screen and open inline detail state.
  • They should not redirect into a canonical content/category/item route because Stalker detail rendering is currently store-state/inline driven, not route driven.
  • Stalker radio favorites/recent items are the exception to VOD/series inline detail opening: they are normalized as live items and must open through the shared live collection audio-player path.
  • VOD-backed series favorites can be displayed in series collections, but detail opening must preserve their VOD origin: is_series=1 favorites set the selected content type to vod so the lazy Ministra season/episode resources run, and embedded series[] favorites render through the embedded VOD-series branch.
  • See Portal Detail Navigation.

Embedded-series snapshot refresh

Favorites and recently-viewed rows store the whole Stalker item as a JSON snapshot, so an embedded series[] episode list (and its playback cmd) freezes at the moment the row was written — newly released episodes would never appear when the item is reopened from favorites, recents, or the dashboard rails. withStalkerSnapshotRefresh() (stores/features/with-stalker-snapshot-refresh.feature.ts) fixes this with a snapshot-first + background re-fetch contract:

  • The stored snapshot renders immediately; the store method refreshEmbeddedSeriesSelection() then re-fetches the item via a portal title search (get_ordered_list&type=vod&search=<title>, item matched by id, paginated up to 5 pages, wildcard-category retry) in the background.
  • When the episode list or cmd changed, the selection is patched in place. The guard requires both the item id and the active playlist id to be unchanged, because Stalker ids are only unique per portal.
  • Only the in-memory selection is patched — the stored snapshot row is deliberately left alone. Every entry path into the detail view runs this refresh, so a stale stored episode list is never rendered for longer than one background request, and writing it back would add an uncontrolled background writer to the whole-playlist read-modify-write that every favorite/recent mutation performs (lost-update risk).
  • Triggers: stalker-collection-detail.component.ts (favorites/recent tabs, global collections, dashboard handoffs) and the optional catalog-facade hook refreshSnapshotSelection() for snapshot-injected browse detail (openStalkerItem navigation state).
  • Regular type=series and Ministra is_series favorites are unaffected — their seasons/episodes are always fetched fresh on open.

Backup and Restore

Versioned playlist backups include Stalker connection metadata plus playlist- scoped favorites/recent snapshots.

Exported fields:

  • portalUrl
  • macAddress
  • isFullStalkerPortal
  • optional username / password
  • optional request headers (userAgent, referrer, origin)
  • full-portal serial/device/signature fields when present
  • favorites and recently viewed collections

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

Import rule:

  • backups restore the saved portal definition and replace the stored favorites/recent state for the matched playlist
  • a fresh handshake must happen after import for full-portal sessions; imported backups never trust a serialized token

Account Info Dialog

StalkerAccountInfoComponent (libs/portal/stalker/feature/src/lib/stalker-account-info/) mirrors the Xtream account-info dialog's visual language and shows subscription facts for a portal: status, login, tariff plan, expiry date with a days-left counter, MAC/phone, and portal details.

Data flow (two sources, cached-first):

  • Cached: Playlist.stalkerAccountInfo, captured from get_profile at import time for portals discovery classified as FULL (endpoint discovery decides this by behavior, so a token-enforcing portal.php panel is a full portal too). The dialog loads it by playlist id (the meta row does not carry it) and renders instantly with a "Saved data" badge.
  • Fresh: StalkerAccountInfoService (libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts). Routing follows the observed MODE, never the endpoint shape: full-mode portals re-run handshake + get_profile, simple-mode panels are queried with account_info/get_main_info, whose field set varies between panels and is mapped best-effort (absent fields render nothing). Both directions re-route after a lazy repair changes the mode mid-request, so a portal repaired from simple to full switches to the profile flow and vice versa. A failed refresh keeps the cached snapshot and flags it. The two no-data outcomes differ: a portal that answers but publishes no account facts (and no cached snapshot exists) renders the ready-state "No account details" panel, while only an unreachable portal without a cached snapshot enters the error state with retry.

Entry points are shared with Xtream and gated on the shared predicates in libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts (isXtreamAccountPlaylist / isStalkerAccountPlaylist): the header playlist switcher (bottom section for the active playlist and the per-row ⋮ menu), the dashboard source card ⋮ menu, and the command palette. The WorkspaceShellHeaderService.openAccountInfoFor() branch picks the dialog by playlist type; WORKSPACE_SHELL_ACTIONS.openStalkerAccountInfo() lazy loads the component. The stalker-mock-server implements get_main_info for dev/E2E.

Remote Control Integration

Stalker live remote control is implemented in:

  • libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts

Supported today:

  • Channel up/down
  • Numeric channel selection (list-position based)
  • Status publish for remote UI (portal/channel/current program)

See full backend and web-remote flow in Remote Control Architecture.

EPG Integration

Stalker ITV now splits EPG usage:

  • active channel panel: bulk get_epg_info cached once per playlist and rendered through the shared EPG panel (app-epg-timeline, or app-epg-list-view in list mode)
  • channel row preview: once ITV channels render, a post-reset effect eagerly starts the de-duplicated bulk get_epg_info load; rows issue no per-row request and derive previews from that cache
  • active panel fallback: get_short_epg when bulk EPG is missing or unsupported

Full details are documented in Stalker Portal EPG Architecture.

Shared/Reusable Infrastructure

Stalker reuses some Xtream UI infrastructure deliberately:

  • Category content rendering route uses Xtream category content component
  • Season container for episodes uses shared Xtream season UI component
  • Playback position handling for series episodes reuses Xtream store position mechanisms
  • Downloads route reuses shared downloads feature

This reduces duplicate UI logic across portal types and keeps compatibility behavior aligned.

Regression Coverage

The compatibility helper and focused regression coverage for Stalker VOD mode branching and the cross-surface series contract live in:

  • libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts
  • libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.ts
  • libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.ts
  • libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts
  • libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
  • libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts

Identity hardening (MAC normalization, derived device IDs, the reported device profile and device-conflict classification) is covered by:

  • libs/shared/interfaces/src/lib/stalker-mac-address.util.spec.ts
  • libs/shared/interfaces/src/lib/stalker-identity.utils.spec.ts
  • libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts
  • libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts
  • libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts
  • libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
  • apps/web-e2e/src/stalker.e2e.ts — "explains a device conflict instead of relaying 'STB is damaged'", which pins a device ID through the proxy and then imports with a different one. Its negative assertion (the generic "refused access" headline must be absent) is what makes it fail if the classification is removed; verified by mutation.

Covered scenarios include:

  • Embedded series[] opens series view state
  • is_series=1 opens lazy series state
  • VOD-backed series favorites keep VOD-series loading semantics when opened from favorites/global favorites
  • Favorite toggle helper path invokes the expected add/remove flow
  • Quick-start episode labels interpolate their episode number
  • Inline and external episode handoffs carry resolved season/episode metadata
  • Dashboard activity classifies is_series VOD as series and resolves its saved episode position

Desktop source health checks

StalkerSourceHealthService checks simple portals with get_main_info through executeStalkerRequest, without the optional repair dependency. Full portals reuse StalkerSessionService.ensureToken and the existing profile API. Health checks never launch endpoint discovery or force renewal of a warm session. Cold authentication retains the shared session slot and its ordinary transport lifetime. The health consumer bounds only its own wait; its deadline and closing a status surface never cancel or shorten authentication shared with playback. Profile/account reads have request-owned cancellation. Outer profile challenge statuses are not subscription statuses. Missing account dates stay unknown.