* 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>
117 KiB
Stalker Portal Architecture
This document describes the Stalker portal implementation in IPTVnator and where each feature is integrated.
Related Docs
- Stalker Portal EPG Architecture
- Playlist Backup/Restore Architecture
- Portal Detail Navigation
- Embedded Inline Playback
- Remote Control Architecture
- Download Manager
- Category Management
- Stalker Store API Baseline
Scope
Stalker support covers:
- Live TV (
itv) - Radio (
radio) - VOD (
vod) - Series (
series) - VOD-as-series flows (
is_series=1and embeddedseries[]) - 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(plusvod/:categoryIdchild)/workspace/stalker/:id/series(plusseries/:categoryIdchild)/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; seedocs/architecture/tmdb-metadata-enrichment.md)/workspace/stalker/:id/downloads(sharedDownloadsComponentfrom@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
-
Angular Stalker screens call methods/resources in
StalkerStore. -
StalkerStorebuilds request params based on selected content type and current view state. -
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 throughStalkerSessionService(handshake + Bearer token + retry), token-free panels callDataService.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_REQUESTthemselves, because each one runs below or before what it routes on:StalkerAuthApi—handshake/get_profile/do_authare 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 useexecuteStalkerRequest(), 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
StalkerPortalRepairServiceitself, 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.StalkerPortalDiscoveryServiceis 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(). -
Electron main process handles
STALKER_REQUESTinapps/electron-backend/src/app/events/stalker.events.ts. -
Axios calls the portal's persisted API endpoint (
portal.phpon reseller panels,server/load.phpon canonical Stalker/Ministra) with required headers/cookies and returns the rawresponse.datato 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, anddo_authrequiresAuthorization: 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 inlibs/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.tsre-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 structuredjs.error/js.msgfields keep the wider phrase set (Invalid token,Auth failed, bareunauthorized), since a panel fills those in deliberately. While a full portal is the active playlist,StalkerSessionServicekeeps a watchdog running — periodic authenticatedwatchdog/get_eventspings 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.phppanel): 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 — themac/stb_lang/timezonecookie, the MAGUser-Agent/X-User-Agentpair and theAccept/Accept-Language/Connectionset (see "Request Transport andcmdEncoding").What it does not get is anything carried by the session. The direct branch of
dispatchStalkerRequest()forwards onlyurl,macAddressandparams, so the builder never sees a token or a serial: noAuthorization: Bearer(there is none to send), and noSNheader or serial-derived__cfduidcookie even when the playlist stores a serial. The full-portal branch forwards both, becausemakeAuthenticatedRequest()passestokenandserialNumber. 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 thesnparameter only ever travels onget_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
CategoryContentViewComponentfrom@iptvnator/portal/catalog/feature(libs/portal/catalog/feature)- Shared category + content layout used by the
vodandseriesroutes (wired instalker-feature.routes.tsvialoadCategoryContentViewComponent)
- Shared category + content layout used by the
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), renderingstalker-collection-detail.component.ts
- Favorites and recently-viewed collection views (
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
paginatedContentlist; page 1 replaces it,hasMoreContentderives from the accumulated length versustotal_items(so a portal that ignores requested page sizes still terminates), and a failed page > 1 setsappendErrorwhile keeping the accumulated pages on screen —retryContentPage()re-runs the same page via the resource'sreload(). The facade splits the resource's loading flag by page: page 0 is the grid skeleton, later pages are the tail spinner, andloadMore()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_linkflow) - Favorites and recently viewed persistence helpers
Internal structure to preserve:
stalker.store.tsstays as the thin facade that composes feature slices.- Cross-slice contracts live in
stores/stalker-store.contracts.tsso feature dependencies are declared instead of repeatedunknowncasts. - Request execution is centralized in
stores/utils/stalker-request.utils.tsfor 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()andgetPaginatedContent(), which now always return arrays, and pair them withisCategoryResourceFailed()/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
undefinedcollections 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, andsignature2values are trimmed, persisted under the canonicalstalker*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 ofdispatchStalkerRequest()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 readsplaylist.stalkerSerialNumberstraight off the row with no mode check at all. So a simple-mode playlist holding a real serial does sendSNand the serial-derived__cfduidon portal-owned streams — while its API calls do not.
- Portal API requests receive the serial through
-
Empty optional identity fields remain absent. IPTVnator must never generate a device ID behind the user's back, and must never duplicate
device_id2fromdevice_id1on its own. -
The legacy default serial value
BEDACD4569BAFis 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
SNor 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__cfduidprotocol cookie. -
Stalker workspace routes must initialize
StalkerStorefrom a playlist object with an explicitisFullStalkerPortalmode. 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
maccookie are the bytes that portal accepted; - rewriting them at the transport would move
stalkerSessionFingerprintfor 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.
applyDerivedDeviceIdscan 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'sSHA256is 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;
- submitting re-runs
-
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 fromgetRawValue()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 infinally, 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, andget_profileis 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 whileisLoading()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_WARNINGinstead once a device ID has actually reached the portal. Storage is not transmission:device_idtravels only onget_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.hasStoredStalkerDeviceIdstherefore gates onisFullStalkerPortal— 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
isFullStalkerPortaltrue while the new value has never seenget_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.stalkerSessionIdentityalready carries a session fingerprint that would serve, but reading it here would make atype:uiplaylist 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 rawhandshake/get_profile/do_authrequests and theauthenticate()orchestration.stalker-watchdog.controller.ts— the periodicget_eventskeep-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 typedStalkerPortalErrorthe 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_timeoutandtimeslotare read for the watchdog cadence. status: 1— refused (device conflict, malformed MAC, disabled account).msg/block_msgcarry the portal's own explanation; they are markup-stripped, combined, and thrown asStalkerPortalError. The kind isdevice-conflictwhenisStalkerDeviceConflictMessagematches the combined text, otherwiseblocked— 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 nomsg, 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 runsdo_auth(login,password, plusdevice_id/device_id2when configured) and retriesget_profilewithauth_second_step=1. Only that retry claims the second auth step — the initial request sendsauth_second_step=0. Missing credentials throwStalkerPortalError('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 acmdthat already contains percent sequences is never double-encoded (the pre-0.23encodeURIComponenttransport delivered%253Aand 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 URLtransport unchanged. - Everything else is percent-encoded. This keeps the injection protection from
the 0.22 hardening:
&,#(and;for PHP setups with a;argument separator) insidecmdcannot 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:cmduses the reference encoding, every other param stays fullyencodeURIComponent-encoded,JsHttpRequest=1-xmlis 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: themac/stb_lang/timezonecookie (plus a serial-derived__cfduid), the MAGUser-Agent/X-User-Agentpair (STALKER_MAG_USER_AGENT),Accept/Accept-Language/Connection, theSNheader andAuthorization: Bearerwhen present, and the serial parameter rule:sntravels only onget_profile(injected there, stripped everywhere else, mirrored into themetricsJSON).
Consumers:
- Electron: the
STALKER_REQUESThandler (apps/electron-backend/src/app/events/stalker.events.ts) feeds both builders directly. - PWA: the renderer (
PwaService.forwardStalkerRequest) sendsmacAddress,token, andserialNumberas control params on the renderer→proxy leg (URLSearchParams, which Express decodes losslessly). The web-backend/stalkerproxy 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 ishandshake, 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
/stalkerroute (apps/stalker-mock-server/src/main.ts) mirrors the proxy with the same shared identity builder, so PWA E2E runs exercise the real contract (includingquery_keys_receiveddiagnostics 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.
Playback Link Resolution
When create_link is called
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 bypassedcreate_linkfor 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
linkFlagsargument onfetchLinkToPlay(), 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.StreamResolverServicecalls 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.
Resolved links are never stored
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_storageis acreate_linkrequest 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_tokenis acreate_linkresponse field for clients that assemble the stream URL themselves. IPTVnator plays thecmdthe 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 nocreate_linkruns at all, so noplay_tokenis ever produced — which is consistent, because a row that wants neither flag is announcing that itscmdneeds 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 survivebuildStalkerSelectedVodItem/normalizeStalkerVodDetailsItem/normalizeStalkerFavoriteItem, and an unflagged row gains no flags.stalker-player-request.utils.spec.ts— static short-circuit, both flags, theseriesexception, relative VOD commands, the session warm-up (simple portal skipped, repaired endpoint used, failure degraded) and the portal-owned-without-session fallback tocreate_link.with-stalker-player.feature.spec.ts— ITV/radio store paths and proof that Recently Viewed stores thecmd, 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 scenario00:1A:79:00:00:0Aserves unflagged ITV rows with a playablecmd; the spec asserts NOcreate_linkrequest 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, sinceportal.example.andportal.exampleare the same host butURLkeeps 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 onlyX-User-Agent),X-User-Agent,SNwhen a real serial exists, andOrigin/Refererset to the portal origin. - Foreign / direct (different host, or an https→http downgrade): the
credential-free
KSPlayerdirect-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 bycreate_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:
itvusestype=itv&action=get_ordered_list, stores results initvChannels, resolves playback throughresolveItvPlayback(...), 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.
radiousestype=radio&action=get_ordered_list, stores results inradioChannels, resolves playback throughresolveRadioPlayback(...), and rendersAudioPlayerComponentinstead 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 usescreate_linkwithtype=radio, skips EPG loading, and renders the sameAudioPlayerComponentlayout 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_RADIOcategory withcategory_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).
onStalkerCategoryClickedtherefore must NOTclearSelectedItem()foritv/radio— the layout gates its player onselectedItem— 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
openStalkerLiveItemIdin history state (a collection's "open in playlist") is served byStalkerLiveAutoOpen(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_channelsaction (type=itv, returns ALL channels in one response — the same call STB clients use); if the portal does not implement it, crawlget_ordered_listpages (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 ignorepand 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'strack 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
unsupportedfor 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 atpageIndex > 1, so an unrelated load completing duplicated the visible page (collidingtrack item.id→ NG0955). TheisCurrentRequestguard is scoped the same way. - Integration: the
getContentResourceloader inwith-stalker-content.feature.tsserves ITV categories from the cache when ready (localtv_genre_idfiltering viafilterItvChannelsByGenre,hasMoreChannels=false), and otherwise runs the legacy paged fetch whileensureLoaded()fills the cache in the background; the resource re-fires via thecacheVersionsignal once the full list arrives. - UI:
StalkerLiveStreamLayoutComponentwindows 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([])whileitvSelectedCategoryFromCache()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()orisPaginatedContentLoading()), not merely an empty channel list; an empty result once loading has settled is an empty category and rendersPORTALS.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 continueget_ordered_listpages 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 numerictv_genre_id; the'*'"All" row's total is stored under theNaNkey thatNumber('*')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 genrecensoredinget_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 pagedget_ordered_listfetch, which still returns those channels. The store computeditvSelectedCategoryFromCacheis the single source of truth for this mode — the live layout keys windowing/infinite-scroll/loadMoreand the category-change reset off it, NOT offitvFullListActive. 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 censoredFor adultsITV 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 inStalkerLiveStreamLayoutComponent— 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 showsStalkerItvAllItemsComponent— 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 legacypagestate, which would re-fire portal requests). Clicking a card runs the sameplayChannelflow 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_channelsand provides thelegacy-paginationscenario 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.
- 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 drivesserialSeasonsResource. It is set purely fromselectedContentType === 'series'— theseriesdetail branch renders<app-stalker-series-view />with novodWithSeriesinput, 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
vodcontent type, which leaves the id unset — otherwise every VOD detail open would fire aget_ordered_list&type=seriesrequest whose result is discarded.
- VOD with Embedded
series[]:
- Item is opened under VOD, but already contains episodes.
StalkerSeriesViewComponentcreates a pseudo-season and renders episodes directly.
- 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., innameoro_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
SxxE01until 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
labelParamsexposes 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. -
StalkerSeriesViewComponentmaps every mode intomappedSeasons()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=1season. 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
seriesXtreamIdand the mappedseasonNumber/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_seriesflag and VOD origin so reopening still uses the lazy Ministra resources. extractStalkerItemType()normalizes those activity records to dashboard typeseries.- The dashboard resolves episode progress by the parent
seriesXtreamIdand 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 VODis_series=1capture 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 portalfirst 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, embeddedseries[], or lazyis_series=1shape; an exact numeric category from the download snapshot replaces a virtualvod/seriescollection 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.tslibs/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. OtherwisereplacePlayercloses 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:moviewhile 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
beforeExternalLaunchhook 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 loadercreatePortalFavoritesResource(...)favorites wrappercreateStalkerDetailViewState(...)unified "open detail" decisiontoggleStalkerVodFavorite(...)shared add/remove behaviornormalizeStalkerEntityId(...)andnormalizeStalkerEntityIdAsNumber(...)for stable ID matchingmatchesFavoriteById(...)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.tslibs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.tslibs/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=1favorites set the selected content type tovodso the lazy Ministra season/episode resources run, and embeddedseries[]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
cmdchanged, 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 hookrefreshSnapshotSelection()for snapshot-injected browse detail (openStalkerItemnavigation state). - Regular
type=seriesand Ministrais_seriesfavorites 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:
portalUrlmacAddressisFullStalkerPortal- 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:
stalkerTokenstalkerSessionIdentity(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 fromget_profileat import time for portals discovery classified as FULL (endpoint discovery decides this by behavior, so a token-enforcingportal.phppanel 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 withaccount_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_infocached once per playlist and rendered through the shared EPG panel (app-epg-timeline, orapp-epg-list-viewin list mode) - channel row preview: once ITV channels render, a post-reset effect eagerly
starts the de-duplicated bulk
get_epg_infoload; rows issue no per-row request and derive previews from that cache - active panel fallback:
get_short_epgwhen 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.tslibs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.tslibs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-compatibility.spec.tslibs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.tslibs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.tslibs/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.tslibs/shared/interfaces/src/lib/stalker-identity.utils.spec.tslibs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.tslibs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.tslibs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.tslibs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.tsapps/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=1opens 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_seriesVOD 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.