Files
iptvnator/docs/architecture/stalker-portal.md
T
4grayandClaude Fable 5 b92503feae feat(stalker): endpoint probing + behavior-based portal mode with lazy repair (#1344)
* feat(stalker): endpoint probing + behavior-based portal mode with lazy repair

Replace the URL-shape guess behind isFullStalkerPortal with real endpoint
discovery: at import, probe portal.php -> server/load.php ->
stalker_portal/server/load.php (the pasted .php endpoint first) and
classify the portal by observed behavior — a token-less itv/get_genres
answering data proves a token-free panel, the middleware's plain-text
auth failure proves the endpoint enforces the token, confirmed by the
real handshake + get_profile. The proven endpoint and mode are persisted.

The three diverging portal-mode predicates (import, session service,
legacy migration) collapse into one shared helper in
@iptvnator/shared/interfaces; executeStalkerRequest becomes the single
request choke point (search and the collection stream resolver fold in),
and the production-dead makeStalkerRequest copy is removed.

Existing misclassified playlists repair themselves lazily: only after a
request actually fails with the plain-text auth bodies, HTTP 404, or a
terminal handshake error, at most once per playlist per session, and only
a configuration discovery proved to answer is persisted — via a minimal
portalUrl/isFullStalkerPortal patch, so favorites, recents and playback
positions survive. Working reseller panels are never probed or rewritten;
there is deliberately no eager one-shot migration, because tolerant
portal.php panels cannot be told apart from misclassified canonical
portals without probing.

The Electron handler now embeds the HTTP status code in the error message
(ipcRenderer.invoke strips custom properties from rejections), and probe
requests carry silent:true so expected 404s do not toast error snackbars.
The stalker mock gains a portal.php-less /ministra host so e2e can prove
the 404 fallthrough end to end.

Fixes #850, #686, #755.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): sync watchdog, PWA proxy errors and cmd resolution with lazy repair

Review round 1 (Greptile P1, Codex P1/P2):

- A successful repair now re-syncs the ACTIVE watchdog playlist via the new
  StalkerSessionService.refreshActiveWatchdogPlaylist(): a simple-to-full
  repair starts the required keepalive mid-session, full-to-simple stops it,
  and an endpoint change repoints the pings instead of leaving them on the
  activation-time snapshot.
- PwaService.forwardStalkerRequest surfaces the web-backend proxy's
  normalized { message, status } no-payload envelope as an HTTP error
  carrying the status, so endpoint discovery and the lazy repair can
  classify upstream 404s in the PWA too (previously payload unwrapping
  returned undefined and dead endpoints were unrepairable there). Probe
  requests pass silent:true and skip the error snackbar.
- fetchStalkerPlaybackLink and the collection StreamResolverService re-apply
  the repair override AFTER the request, so a relative create_link reply
  resolves against the endpoint that actually answered (the resolver keeps
  the /stalker_portal path segment as base, so this matters beyond origin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): parse candidate URLs and tie repair overrides to their source config

Review round 2 (Codex P2 x2):

- Endpoint candidates are now derived from the parsed origin + pathname:
  a pasted URL carrying a query or fragment (host/c?key=value) no longer
  gets /portal.php bolted onto the query, which made every probe hit /c
  and persisted the non-API URL.
- A repair override is tied to the failing configuration it replaced.
  Playlists carrying anything else (the user edited the portal URL or mode
  through the playlist dialog) drop the override and re-arm the
  once-per-session probe latch, so edited metadata is used verbatim and
  may repair again if it fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): auth-gated probes, normalized offline fallback, mock docs sync

Review round 3 (Codex P1 x2, P2):

- A probe answered with HTTP 401/403 now classifies the endpoint as
  auth-required and attempts the real handshake instead of skipping the
  candidate: non-standard middlewares answer 401 where the stock server
  sends HTTP 200 + plain text, and such portals authenticated fine before
  discovery existed.
- The unreachable-host import fallback normalizes the pasted URL (origin +
  pathname) before the legacy /c -> portal.php rewrite, so a query or
  fragment can no longer make it persist the browser page URL - a 200 HTML
  answer from /c is not a repair trigger, which would have left the
  playlist empty for good.
- The stalker mock-server README and architecture doc now describe
  behavior-based discovery and the /ministra host instead of the retired
  URL-shape rule and its "known inconsistency" note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): recognize JSON auth failures and guard repairs against mid-probe edits

Review round 4 (Codex P1 + P2):

- isStalkerAuthFailureResponse() recognizes the JSON envelope some panels
  answer instead of the plain-text body ({js:{error:"Authorization
  failed"}} / {js:{msg:...}}). Probe classification treats it as
  auth-required instead of token-free data, and the lazy-repair trigger
  fires on it at runtime — previously such a portal was persisted simple
  with no repair path at all.
- A repair is committed only after re-reading the persisted row and
  verifying it still carries the configuration that failed: a user who
  edits the portal URL (or deletes the playlist) during the multi-second
  probe now wins over the in-flight repair result for the old URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): probe past endpoint 5xx, sibling fallbacks, identity-aware repair guard

Review round 5 (Codex P2 x3):

- A probe that fails with a RESOLVABLE HTTP status keeps discovery going:
  a broken /portal.php handler answering 500 must not hide a healthy
  sibling endpoint. Only status-less failures (true network level) stop
  the loop. The Electron handler now gives real HTTP 5xx responses the
  same parseable "HTTP Error <code>" message shape as 4xx, so the
  renderer can tell them apart from ECONNREFUSED/timeouts after
  ipcRenderer strips the object shape.
- Standard fallback candidates for a nonstandard pasted endpoint
  (.../cp/api.php) derive from its DIRECTORY, so recovery probes hit
  /cp/portal.php instead of /cp/api.php/portal.php.
- The repair's row re-verification also compares the MAC and all Stalker
  identity fields: a probe authenticated as the old identity must not
  install its token/watchdog or persist onto a row whose credentials were
  edited mid-probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): reactivation-safe watchdog, wider JSON auth phrases, per-config probe latch

Review round 6 (Greptile 4/5 concern + Codex P1/P2):

- setCurrentPlaylist applies the repair override before feeding the
  watchdog and store state: re-activating the portal route with the stale
  NgRx meta no longer stops or repoints the repaired keepalive back to
  the broken configuration.
- The structured js.error/js.msg fields accept the full phrase set the
  session service recognizes (Invalid token, Auth failed, bare
  unauthorized/authorization) — panels answering those envelopes were
  still classified token-free. Plain-text body matching stays narrow on
  purpose (HTML false positives).
- The once-per-session probe latch is keyed by the SOURCE configuration
  fingerprint (endpoint, mode, MAC, identity) instead of the playlist id:
  a repair discarded because of a mid-probe edit no longer blocks the
  edited configuration from repairing, while stale snapshots of an
  already-probed configuration still cannot loop the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): identity-aware override invalidation and timeout-tolerant probing

Review round 7 (Greptile P1 + Codex P2):

- The repair override records the identity fingerprint the probe
  authenticated as. Editing the MAC or any Stalker identity field
  afterwards drops the override, the per-config probe latch AND the cached
  token, so requests and watchdog pings never pair the edited identity
  with a session negotiated for the previous one.
- A status-less probe failure that is a TIMEOUT (renderer budget, axios
  request timeout, ETIMEDOUT) continues to the next candidate — one
  hanging handler must not hide healthy siblings; connection-level
  failures (refused, unresolvable host) still stop discovery, so dead
  hosts keep failing fast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): watchdog pings authenticate as the persisted row

Review round 8 (Greptile 4/5 concern):

The watchdog held its activation-time playlist snapshot for the whole
session, so portal metadata edited (or repaired) mid-session kept the
keepalive authenticating as the previous identity/endpoint — its pings
could keep the old session alive and repopulate the playlist-scoped token
cache with a token for the pre-edit identity.

Each ping now resolves the playlist from the persisted row first (the
single source of truth), falling back to the snapshot only when the store
cannot be read, and refreshes the snapshot on every successful read. Any
edit — identity, endpoint or mode — reaches the keepalive within one ping
cycle; a row now marked simple (or deleted) stops the watchdog. The
in-flight guard is claimed before the row read so overlapping pings
cannot double-fire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): identity-tagged tokens, watchdog override overlay, retire-on-failure

Review round 9 (Greptile 4/5 concern + Codex P2):

- The session token cache is tagged with the identity fingerprint (MAC +
  all Stalker identity fields) the session was negotiated for; ensureToken
  re-authenticates instead of handing an edited identity the previous
  token. The fingerprint helper is shared (stalker-identity.utils) with
  the repair layer's override/latch checks.
- Watchdog pings overlay the repair layer's in-session override on the
  resolved row (registered decorator, no import cycle): a simple-to-full
  repair whose persistence is pending or failed no longer reads the stale
  row and stops the freshly started keepalive.
- makeAuthenticatedRequest retires a failed token even on the no-retry
  path (watchdog pings), so a dead session is never handed to the next
  caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): pending authentications are identity-scoped

Review round 10 (Greptile 4/5 concern):

pendingAuth entries carry the identity fingerprint they authenticate as.
A request for an edited identity no longer adopts an in-flight result
negotiated for the previous identity: it waits the old authentication out
(a competing handshake would strand it with a dead token on strict
portals) and then negotiates its own session. This was the last
id-only-keyed session structure — override, probe latch, token cache,
watchdog snapshot and pending auth are now all identity-aware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): atomic repair persistence, full probe history, normalized offline classify

Review round 11 (Codex P2 x3 + P1 docs):

- The repair's row verification and patch now run ATOMICALLY inside the
  per-playlist write queue via the new
  PlaylistsService.transformPlaylistMeta(): a user edit that is queued but
  not yet committed wins over the repair — the transform sees the edited
  row and aborts instead of overwriting it. Write failures after a
  successful verification keep the session-only override, read failures
  discard the repair.
- The per-playlist probe latch keeps EVERY attempted source fingerprint,
  so alternating edits (A -> B -> A) cannot evict a fingerprint and let
  stale snapshots re-run discovery.
- The unreachable-host import fallback classifies the normalized
  origin+pathname, so a query merely mentioning /server/load.php cannot
  make a panel URL look canonical and abort the offline import.
- docs/architecture/stalker-portal.md documents the actual probe
  sequencing: any resolvable HTTP status (incl. 5xx) and timeouts continue,
  401/403 classify as auth-required, only connection-level failures abort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): collision-proof session fingerprints

Review round 12 (Greptile P1): identity values are unrestricted strings,
so the delimiter-joined fingerprint could alias distinct identity tuples
(serial "a|b" + empty device vs serial "a" + device "b") and bypass the
identity invalidation. Both the identity fingerprint and the repair
source fingerprint are JSON-encoded now; regression test pins the exact
aliasing pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): preserve URL authority in normalization; document per-config latch

Review round 13 (Codex P1 docs + P2):

- normalizeStalkerPortalInputUrl mutates the parsed URL (clear query/
  fragment, trim pathname) instead of rebuilding from origin, and the
  candidate builder swaps only the path — file: URLs (origin "null") no
  longer make the builder throw, and basic-auth credentials are not
  silently dropped before probing.
- The canonical docs and the repair service JSDoc now describe the actual
  loop guard: at most one probe per SOURCE CONFIGURATION (endpoint, mode,
  MAC, identity) per playlist per session, not once per playlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): HTTP 401/403 failures trigger the lazy repair

Review round 14 (Codex P1): discovery classifies 401/403 endpoints as
auth-required, but the repair trigger accepted only 404 — a legacy
playlist misclassified token-free against an HTTP-auth-gated middleware
could never reach discovery and stayed unusable. 401/403 now qualify;
endpoint-specific 5xx still do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): re-enter repair for edited configurations after a pending probe

Review round 15 (Codex P2): a request carrying an edited configuration
that raced an in-flight probe only awaited it and inherited its outcome —
the edited fingerprint stayed unattempted and the first request failed
without triggering its own discovery. repairPortal now re-enters after
awaiting the pending probe, so the per-config latch decides: already
attempted -> reapply, never attempted -> own probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): probe history remembers outcomes so restored configs repair again

Review round 16 (Greptile P1): the per-config latch kept A's fingerprint
after an edit to B dropped A's override, so restoring A left it latched
with nothing to reapply — broken until restart. The history now stores
each probe's OUTCOME (override or null): a restored configuration
reinstalls its remembered repair without a second discovery, and the
anti-ping-pong property (A<->B alternation never re-runs discovery from
stale snapshots) is preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playlist): serialize deletion behind the per-playlist write queue

Review round 17 (Codex P2): deletePlaylist bypassed
serializePlaylistWrite, so a queued mutation (e.g. the Stalker portal
repair's conditional transform) finishing after an unserialized delete
could upsert the row back and resurrect the playlist. Deletion now runs
through the same queue: queued writes commit first, the delete lands
last, and a transform enqueued after the delete reads a missing row and
aborts. Regression test pins the write-then-delete ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): reinstalled repairs re-sync the watchdog like fresh ones

Review round 18 (Greptile P1): the restored-configuration branch
reinstalled the remembered override without the watchdog refresh the
fresh-repair path performs — if the intermediate edit stopped the
keepalive, the restored full-portal session recovered requests but never
its pings. The reinstall now calls refreshActiveWatchdogPlaylist with the
override applied, symmetric with a fresh repair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): discarded probes retry once their configuration is restored

Review round 19 (Greptile P1): the pre-probe history reservation survived
the row-mismatch discard, so restoring the original configuration hit the
latch with nothing to reinstall — lazy repair stayed disabled for the
session. Probe records are now explicit (override / no-change /
discarded): a discarded configuration probes again once one cheap row
read confirms the row was RESTORED to it, while stale snapshots of it
stay declined without a discovery run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): IPC-safe transport errors, repairable profile path, nested base paths

Review round 20 (Codex P2 x4):

- The Electron handler throws a real Error for axios failures without a
  response: Electron serializes rejections via toString(), so a plain
  object arrived as "[object Object]" and discovery could not tell a
  timeout (keep probing) from a dead host (stop).
- isAuthorizationError parses HTTP 401/403 out of the IPC-wrapped message,
  so an expired-token 403 retires the token and re-authenticates instead
  of surfacing as a plain failure.
- The account-info full-profile path (which bypasses
  executeStalkerRequest) routes repair-trigger failures through
  StalkerPortalRepairService and retries with the repaired playlist, so
  opening the dialog can fix a stale endpoint.
- resolveStalkerPlaybackUrl derives the installation base from the
  endpoint's API suffix instead of a fixed stalker_portal|c|portal
  allowlist: relative create_link replies now resolve correctly under
  arbitrary discovered installations such as /cp/server/load.php.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): strict probe data shape, mode-aware profile retry, docs API name

Review round 21 (Codex P1 docs + P2 x2):

- Probe classification requires the real get_genres shape (array, or a
  {data: []} envelope without an error) instead of a bare `js` key: a 200
  error envelope ({js:{error:"Unknown action"}}, {js:false}) no longer
  ends discovery on a broken candidate and persists an empty catalog.
- After a repair that flips the portal to simple mode, the account-info
  retry re-enters the mode routing and uses get_main_info instead of
  handshaking against a token-free panel again.
- docs/architecture/stalker-portal.md names transformPlaylistMeta and its
  atomic source-check invariant (plus the serialized deletion) rather than
  the race-prone updatePlaylistMeta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): account dialog re-routes after a simple-to-full repair

Review round 22 (Codex P2): fetchViaMainInfo runs through
executeStalkerRequest, whose lazy repair retries the SAME action, so a
repair proving the portal is actually full left the dialog calling
get_main_info — canonical installations publish subscription details only
through handshake + get_profile, leaving the dialog empty. The routing is
now symmetric with the full-to-simple case: an empty main-info result
whose repair flipped the mode re-enters the profile flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): row-gate override reinstall; document mode-based account routing

Review round 23 (Codex P2 + P1 docs):

- Reinstalling a remembered override now requires the persisted row to
  actually carry that configuration again. A stale request for A while the
  row holds an unrelated C no longer resurrects A's override, which would
  retry against B and repoint the active watchdog away from C. (The
  edit-back-to-A case stays as documented: there the row IS A.)
- docs/architecture/stalker-portal.md and CLAUDE.md describe account-info
  routing by the observed portal MODE instead of the endpoint shape — a
  token-enforcing portal.php is a full portal now — and note the
  mode-change re-routing in both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): share the auth-failure predicate; prefer profile over partial main-info

Review round 24 (Codex P1 + P2):

- isAuthorizationError now reuses isStalkerAuthFailureResponse, so the
  phrases discovery and the lazy repair already classify as auth failures
  (Access denied., Unauthorized request., and their JSON envelopes) also
  retire the session token. Previously a full portal expiring with either
  phrase kept its dead token: the repair rediscovered the same
  endpoint/mode, recorded no-change, and every later request stayed broken.
- After a simple-to-full repair, even a PARTIAL get_main_info answer no
  longer wins over the profile flow — expiry and tariff live only behind
  handshake + get_profile. The partial facts are kept only if the profile
  path itself publishes nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): keep a literal c installation directory in candidate derivation

Review round 25 (Codex P2): the /c landing-page rewrite ran after the
endpoint file was stripped, so `/tenant/c/portal.php` collapsed to
`/tenant` and the sibling probes went one level too high, rejecting a
valid portal whose installation directory is literally named `c`. The
rewrite now applies only when the pathname itself ends in `/c` (no
endpoint file); pasted endpoints strip only the file part.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): route rejected post-repair main-info retries to the profile flow

Review round 26 (Codex P2): a simple-to-full repair during
fetchViaMainInfo makes executeStalkerRequest retry the same action against
the repaired full portal, and installations that do not implement
get_main_info answer 404 — the rejection escaped before the repaired-mode
check, so the dialog failed instead of switching to get_profile. The
rejection is captured and reaches the same check; without a mode change it
is rethrown unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): full predicate for wrapped denials; record the removed store prop

Review round 27 (Codex P2 + P1 docs):

- The repair trigger applies the shared auth-failure predicate to the error
  MESSAGE too, so authentication's wrapped structured denials
  (Error('Profile error: Access denied.')) reach the repair instead of
  bypassing it and leaving a healthy sibling endpoint unprobed.
- docs/architecture/stalker-store-api-baseline.md records makeStalkerRequest
  as removed, with the reason it gets no facade alias: it was
  production-dead and held a fourth private copy of the portal-mode branch
  that the shared predicate exists to prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): complete auth predicate for wrapped error messages

Review round 28 (Codex P2): the plain-text BODY matcher deliberately knows
only the three middleware phrases, so passing an error message through it
let authenticate()'s wrapped denials — Error('Profile error: Invalid
token') / 'Auth failed' — bypass both the repair trigger and the session
auth predicate. A dedicated isStalkerAuthFailureMessage() applies the wide
phrase set to controlled error strings, while arbitrary portal bodies keep
the narrow matcher that cannot false-positive on HTML pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stalker): reject denied profiles during confirmation; document all repair triggers

Review round 29 (Codex P2 + P1 docs):

- Full-portal confirmation validates the get_profile envelope with the
  shared structured predicate: a handshake can hand out a token whose
  profile still answers {js:{error:"Invalid token"}}, and authenticate()
  inspects only msg/block_msg — discovery would have persisted an unusable
  endpoint and stopped before the healthy sibling. authenticate() now
  returns the raw profile response for that check.
- The canonical lazy-repair contract lists the complete trigger set: the
  plain-text bodies AND their JSON envelopes, HTTP 404, HTTP 401/403, and
  terminal handshake/profile errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:01:45 +02:00

43 KiB

Stalker Portal Architecture

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

Scope

Stalker support covers:

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

Routing Structure

Primary route tree lives in libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts.

  • /stalker/:id/vod (plus vod/:categoryId child)
  • /stalker/:id/series (plus series/:categoryId child)
  • /stalker/:id/itv
  • /stalker/:id/radio
  • /stalker/:id/favorites
  • /stalker/:id/recent
  • /stalker/:id/search
  • /stalker/:id/actor/:personId
  • /stalker/:id/downloads (shared DownloadsComponent from @iptvnator/portal/downloads/feature)
  • /stalker/:id/downloads/:downloadId (focused local movie/series detail with no category context panel)

Runtime Architecture

  1. Angular Stalker screens call methods/resources in StalkerStore.
  2. StalkerStore builds request params based on selected content type and current view state.
  3. Every portal API call funnels through executeStalkerRequest() (libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts), the single choke point that decides the transport per portal mode: full portals go through StalkerSessionService (handshake + Bearer token + retry), token-free panels call DataService.sendIpcEvent(STALKER_REQUEST, ...) directly. It also hooks the lazy portal repair (see "Portal Mode and Endpoint Discovery").
  4. Electron main process handles STALKER_REQUEST in apps/electron-backend/src/app/events/stalker.events.ts.
  5. Axios calls the portal's persisted API endpoint (portal.php on reseller panels, server/load.php on canonical Stalker/Ministra) with required headers/cookies and returns the raw response.data to the renderer; normalization happens in the store feature slices.

Portal Mode and Endpoint Discovery

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

  • Full portal (canonical Stalker/Ministra middleware): every request except handshake, get_profile, get_localization, and do_auth requires Authorization: Bearer <token>; auth failures are HTTP 200 with a plain-text body (Authorization failed., Access denied., Unauthorized request.), never a 401/403. While a full portal is the active playlist, StalkerSessionService keeps a watchdog running — periodic authenticated watchdog/get_events pings (currently every 25 s; the protocol default expects 120 s, tracked for a later PR) whose failures are non-fatal.
  • Simple portal (reseller-style portal.php panels): no auth lifecycle at all — requests carry only the mac= cookie.

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). portal.php does not exist in official Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint derived from a …/c URL is <base>/server/load.php. Instead of guessing from the URL shape, StalkerPortalDiscoveryService (libs/portal/stalker/data-access) probes candidates in order — the pasted URL itself when it already names a .php endpoint, then <base>/portal.php → <base>/server/load.php → <base>/stalker_portal/server/load.php — and classifies each endpoint by observed behavior: a token-less itv/get_genres that returns data proves a token-free panel; the plain-text auth failure proves the endpoint enforces the token, which is confirmed by running the real handshake + get_profile. The import dialog persists the proven endpoint and mode. When no candidate answers at all, panel-style URLs fall back to the pre-discovery behavior (legacy …/c → portal.php rewrite, simple mode, import succeeds with a warning) so temporarily offline panels can still be added; canonical-shaped URLs abort like the old mandatory handshake did (both classifications run on the normalized origin + pathname form). Probe failure sequencing: ANY resolvable HTTP status moves to the next candidate — 4xx means the endpoint is absent, a 5xx can be one broken handler beside a healthy sibling — and 401/403 specifically classify as auth-required (the handshake is attempted, for middlewares that answer HTTP auth codes instead of the stock 200 + 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.

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) per playlist per session — an edited configuration may probe when it fails, while every already-probed one stays latched for the session — and 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) and 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. Deletion runs through the same queue, so a repair can never resurrect a playlist deleted mid-probe. Portals that work are never probed, let alone rewritten. E2E coverage: apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts against the mock's tolerant /portal.php, strict /server/load.php, and portal.php-less /ministra/* hosts.

Main UI Components

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

Store and Data Flow

Stalker store is now feature-composed:

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

Important store responsibilities:

  • Selected content/category/item state
  • Category and paginated content resources
  • ITV channel list + pagination (full-list session cache when the portal supports it, legacy 14-per-page lazy loading otherwise)
  • Radio category/station list + pagination
  • Regular series seasons resource
  • VOD-series (is_series=1) seasons + episodes resources
  • Playback link creation (create_link flow)
  • Favorites and recently viewed persistence helpers

Internal structure to preserve:

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

Failure-handling rule:

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

Stalker Identity Policy

Full Stalker/Ministra portal authentication defaults to MAC-only identity. The import UI can capture optional serial number, device IDs, and signatures, but blank fields are not generated or forwarded to get_profile.

  • User-provided sn, device_id, device_id2, signature, and signature2 values are trimmed, persisted under the canonical stalker* playlist fields, and reused for initial auth, token refresh, retry auth, normal API requests, and same-origin playback headers.
  • Empty optional identity fields remain absent. IPTVnator must not generate a device ID from the MAC address or duplicate device_id2 from device_id1.
  • The legacy default serial value BEDACD4569BAF is treated as absent at runtime so older blank imports do not keep sending a synthetic serial number.
  • Playback headers use the same serial normalization, so the legacy default is not sent as SN or as a serial-derived __cfduid. MAC-only API and playback requests do not synthesize __cfduid; when a real serial is present, same-origin playback uses a canonical 32-character __cfduid protocol cookie.
  • Generated MAG-like identity remains a future explicit setting. It must not be the default because strict portals can bind accounts to the first device fingerprint they receive.
  • Stalker workspace routes must initialize StalkerStore from a playlist object with an explicit isFullStalkerPortal mode. If the active route metadata is a lightweight playlist record without that field, the route session must load the full playlist by id before category/content resources run. Stalker auth metadata is independent from M3U playlist EPG metadata and must not depend on M3U-specific EPG fields.

Request Transport and cmd Encoding

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

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

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

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

Consumers:

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

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

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

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, applied by WebPlayerViewComponent for the video players and by the Stalker live layout for the radio audio player, which renders outside WebPlayerViewComponent — see docs/architecture/electron-security.md, "Scoped Request Header Overrides").

Two stream profiles exist, selected by one shared predicate:

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

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

Live TV and Radio

The Stalker live route and radio route intentionally share StalkerLiveStreamLayoutComponent:

  • itv uses type=itv&action=get_ordered_list, stores results in itvChannels, resolves playback through resolveItvPlayback(...), and keeps the EPG panel visible.
  • ITV additionally loads the COMPLETE channel list once per portal session (see "Full ITV channel list cache" below), so category views and search are not limited to the lazily loaded 14-item pages.
  • radio uses type=radio&action=get_ordered_list, stores results in radioChannels, resolves playback through resolveRadioPlayback(...), and renders AudioPlayerComponent instead of a video player.
  • Radio hides the EPG panel and must not call Stalker EPG endpoints because radio stations do not have EPG data.
  • Radio always uses the inline audio player. External player settings are ignored for Stalker radio, matching M3U radio behavior.
  • Radio stations opened from favorites or recently viewed remain live collection items with radio: 'true'; the shared collection resolver uses create_link with type=radio, skips EPG loading, and renders the same AudioPlayerComponent layout instead of the Stalker VOD detail layout.
  • Some Stalker portals do not expose radio categories. Radio category loading falls back to a synthetic PORTALS.ALL_RADIO category with category_id: '*' so the station list can still be loaded.

Full ITV Channel List Cache

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

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

VOD/Series Modes

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

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

  1. Regular Series (/series):
  • Seasons come from API resource (serialSeasonsResource).
  • Episodes are derived from season payload.
  • This is the only mode that sets selectedSerialId, which is what drives serialSeasonsResource. It is set purely from selectedContentType === 'series' — the series detail branch renders <app-stalker-series-view /> with no vodWithSeries input, so the API resource is its only episode source and the fetch must never be gated on item shape.
  • Modes 2 and 3 below are always opened under the vod content type, which leaves the id unset — otherwise every VOD detail open would fire a get_ordered_list&type=series request whose result is discarded.
  1. VOD with Embedded series[]:
  • Item is opened under VOD, but already contains episodes.
  • StalkerSeriesViewComponent creates a pseudo-season and renders episodes directly.
  1. VOD with is_series=1 (Ministra plugin behavior):
  • Treated as series flow from VOD context.
  • Seasons are fetched lazily.
  • Episodes are fetched on season select.
  • The series quick-start CTA can load the first unloaded VOD-series season before playback. Unloaded seasons are considered unplayed in full season order, so an earlier unloaded season is not skipped just because a later season was loaded manually. If all currently loaded episodes are watched and more season metadata exists, quick start loads the next unloaded season instead of showing the completed state. After a lazy load, quick start is recomputed from the mapped episodes before playback so provider episode ordering cannot start the wrong episode.
  • For unloaded VOD-series seasons, the CTA target label is derived from season metadata and rendered as SxxE01 until episode details are loaded.
  • Lazy VOD-series episodes use scoped tracking IDs derived from the parent series ID, provider episode ID, season key, and episode number. The season key follows the mapping fallback (season_number, then name, then ID).
  • The previous season/episode hash remains available only as a compatibility alias in legacyTrackingId. The scoped ID is the in-memory episode key, and new playback positions always use it.
  • Quick-start actions preserve both their translation key and interpolation parameters when adapted for the Stalker CTA. Dropping labelParams exposes the raw {{episode}} placeholder.

Series inline playback behavior is shared across all three modes:

  • StalkerSeriesViewComponent maps every mode into mappedSeasons() and derives the currently playing episode from inlinePlayback.contentInfo.contentXtreamId.
  • The inline player header shows the current episode metadata below the title, for example S01E03 - Episode title.
  • Embedded players receive previous/next episode state for the current season only.
  • Inline series autoplay is enabled by default. On player EOF (ended), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
  • Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded is_series=1 season. Quick start remains the only flow that may load another VOD-series season before playback.
  • Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.
  • Before either inline or external playback starts, the resolved content info includes the parent seriesXtreamId and the mapped seasonNumber / episodeNumber. Future playback-position rows therefore carry enough metadata for workspace surfaces to render an episode badge. Existing rows without those fields are intentionally not migrated and remain badge-less until the episode is played again.
  • Ministra payloads may omit season_number. Episode mapping and lazy quick-start labels share the same naturally ordered season fallback so later seasons are not persisted as season 1.

Playback Position Identity and Compatibility

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

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

The VOD-series contract is cross-surface:

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

Core decision logic and normalization are centralized in:

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

Favorites and Recently Viewed

Current implementation is shared via Stalker-specific helpers:

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

Where this is used:

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

Navigation rule to preserve:

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

Embedded-series snapshot refresh

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

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

Backup and Restore

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

Exported fields:

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

Excluded fields:

  • stalkerToken
  • stalkerAccountInfo
  • playback positions in backup v1

Import rule:

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

Account Info Dialog

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

Data flow (two sources, cached-first):

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

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

Remote Control Integration

Stalker live remote control is implemented in:

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

Supported today:

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

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

EPG Integration

Stalker ITV now splits EPG usage:

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

Full details are documented in Stalker Portal EPG Architecture.

Shared/Reusable Infrastructure

Stalker reuses some Xtream UI infrastructure deliberately:

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

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

Regression Coverage

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

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

Covered scenarios include:

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