* 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>
43 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.
/stalker/:id/vod(plusvod/:categoryIdchild)/stalker/:id/series(plusseries/:categoryIdchild)/stalker/:id/itv/stalker/:id/radio/stalker/:id/favorites/stalker/:id/recent/stalker/:id/search/stalker/:id/actor/:personId/stalker/:id/downloads(sharedDownloadsComponentfrom@iptvnator/portal/downloads/feature)/stalker/:id/downloads/:downloadId(focused local movie/series detail with no category context panel)
Runtime Architecture
- Angular Stalker screens call methods/resources in
StalkerStore. StalkerStorebuilds request params based on selected content type and current view state.- 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 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"). - 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. While a full portal is the active playlist,StalkerSessionServicekeeps a watchdog running — periodic authenticatedwatchdog/get_eventspings (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.phppanels): no auth lifecycle at all — requests carry only themac=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
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 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_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.
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
blank fields are not generated or forwarded to get_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. - Empty optional identity fields remain absent. IPTVnator must not generate a
device ID from the MAC address or duplicate
device_id2fromdevice_id1. - 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. - 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
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.
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 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 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 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,Range,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.
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:
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.
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([])whileitvFullListActive()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: 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 thedegraded-loaded-only/ "loaded only" status for Stalker ITV onceitvFullListActive; 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 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— a paginated card grid of every channel in the portal (client-side pagination only; 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 keep server-side search; 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 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:
StalkerSeriesViewComponentmaps every mode intomappedSeasons()and derives the currently playing episode frominlinePlayback.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=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
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:
stalkerTokenstalkerAccountInfo- 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
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