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>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 18:01:45 +02:00
1 parent 65f81b7110
commit b92503feae
49 files changed
+4765 -434

No files matched your search

@@ -2,17 +2,23 @@ import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
describe('StalkerPortalImportComponent identity handling', () => {
let component: StalkerPortalImportComponent;
let stalkerSession: { authenticate: jest.Mock };
let portalDiscovery: { discover: jest.Mock };
let store: { dispatch: jest.Mock };
beforeEach(() => {
stalkerSession = {
authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }),
portalDiscovery = {
discover: jest.fn().mockResolvedValue({
status: 'resolved',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
}),
};
store = {
dispatch: jest.fn(),
@@ -20,7 +26,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
TestBed.configureTestingModule({
providers: [
{ provide: StalkerSessionService, useValue: stalkerSession },
{
provide: StalkerPortalDiscoveryService,
useValue: portalDiscovery,
},
{ provide: Store, useValue: store },
{
provide: MatSnackBar,
@@ -54,8 +63,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/server/load.php',
expect(portalDiscovery.discover).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/c',
'00:1A:79:AA:BB:CC',
{
serialNumber: 'CUSTOMSN123',
@@ -69,6 +78,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist).toEqual(
expect.objectContaining({
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
stalkerToken: 'token-1',
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
@@ -99,8 +112,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/server/load.php',
expect(portalDiscovery.discover).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/c',
'00:1A:79:AA:BB:CC',
{}
);
@@ -117,4 +130,52 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(playlist.signature1).toBeUndefined();
expect(playlist.signature2).toBeUndefined();
});
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
// A query merely MENTIONING /server/load.php must not make a
// panel-style /c URL look canonical and abort the offline import.
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
_id: 'playlist-3',
title: 'Query Panel',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://panel.example.com/c?redirect=/server/load.php',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist).toEqual(
expect.objectContaining({
portalUrl: 'https://panel.example.com/portal.php',
isFullStalkerPortal: false,
})
);
});
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
// Offline panel: discovery finds nothing, the legacy guess imports
// anyway — but the suffix rewrite must run on the PATH, or
// `/c?key=value` would persist the browser page instead of
// portal.php (and a 200 HTML answer is not a repair trigger later).
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
_id: 'playlist-2',
title: 'Offline Panel',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://panel.example.com/c?key=value',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist).toEqual(
expect.objectContaining({
portalUrl: 'https://panel.example.com/portal.php',
isFullStalkerPortal: false,
})
);
});
});
@@ -13,11 +13,17 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
StalkerPortalDiscoveryService,
StalkerPortalIdentity,
StalkerSessionService,
normalizeStalkerPortalIdentity,
} from '@iptvnator/portal/stalker/data-access';
import { createRandomId, Playlist } from '@iptvnator/shared/interfaces';
import {
createRandomId,
isFullStalkerPortalUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
@Component({
imports: [
@@ -72,7 +78,7 @@ export class StalkerPortalImportComponent {
userAgent: new FormControl(''),
});
private readonly stalkerSessionService = inject(StalkerSessionService);
private readonly portalDiscovery = inject(StalkerPortalDiscoveryService);
private readonly store = inject(Store);
private readonly snackBar = inject(MatSnackBar);
readonly translate = inject(TranslateService);
@@ -107,9 +113,6 @@ export class StalkerPortalImportComponent {
try {
const formValue = this.form.getRawValue();
const originalUrl = formValue.portalUrl ?? '';
const transformedUrl = this.transformPortalUrl(originalUrl);
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
const stalkerIdentity = normalizeStalkerPortalIdentity({
serialNumber: formValue.serialNumber ?? undefined,
deviceId1: formValue.deviceId1 ?? undefined,
@@ -118,55 +121,88 @@ export class StalkerPortalImportComponent {
signature2: formValue.signature2 ?? undefined,
});
// Probe candidate endpoints and classify the portal by observed
// behavior (does it enforce the handshake token?) instead of
// guessing from the URL shape — the guess persisted broken
// configurations for canonical `…/server/load.php` portals and
// rewrote `…/c` to a `portal.php` official Ministra never serves.
const discovery = await this.portalDiscovery.discover(
originalUrl,
formValue.macAddress ?? '',
stalkerIdentity
);
let portalUrl: string;
let isFullStalkerPortal: boolean;
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
// For full stalker portal URLs, perform handshake and get profile
if (isFullStalkerPortal) {
try {
const authResult =
await this.stalkerSessionService.authenticate(
transformedUrl,
formValue.macAddress ?? '',
stalkerIdentity
);
if (discovery.status === 'resolved') {
portalUrl = discovery.portalUrl;
isFullStalkerPortal = discovery.isFullStalkerPortal;
stalkerToken = discovery.token;
stalkerToken = authResult.token;
if (discovery.accountInfo) {
stalkerAccountInfo = {
login: discovery.accountInfo.login,
expireDate: discovery.accountInfo.expire_date,
tariffPlanName:
discovery.accountInfo.tariff_plan_name,
status: discovery.accountInfo.status,
};
}
if (authResult.accountInfo) {
stalkerAccountInfo = {
login: authResult.accountInfo.login,
expireDate: authResult.accountInfo.expire_date,
tariffPlanName:
authResult.accountInfo.tariff_plan_name,
status: authResult.accountInfo.status,
};
}
// Show success notification with account info if available
if (stalkerAccountInfo?.expireDate) {
const expireDate = new Date(
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
undefined,
{ duration: 3000 }
);
}
} catch (error) {
console.error(
'[StalkerImport] Authentication failed:',
error
if (stalkerAccountInfo?.expireDate) {
const expireDate = new Date(
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
undefined,
{ duration: 5000 }
{ duration: 3000 }
);
this.isLoading.set(false);
return;
}
} else if (discovery.status === 'auth-rejected') {
console.error(
'[StalkerImport] Authentication failed:',
discovery.error
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
undefined,
{ duration: 5000 }
);
return;
} else if (
isFullStalkerPortalUrl(
normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl
)
) {
// Unreachable host on a canonical-portal URL shape: the old
// flow aborted here too (its mandatory handshake could not
// succeed either).
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
undefined,
{ duration: 5000 }
);
return;
} else {
// Unreachable host on a panel-style URL: import with the
// legacy guess exactly like before discovery existed, so a
// temporarily offline panel can still be added. The lazy
// portal repair re-probes on the first real failure.
// Normalized first: the legacy suffix rewrites run on the
// path, so a query/fragment must not hide a trailing `/c`.
portalUrl = legacyTransformStalkerPortalUrl(
normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl
);
isFullStalkerPortal = false;
this.snackBar.open(
'Portal did not respond; added without validation.',
undefined,
{ duration: 5000 }
);
}
const {
@@ -180,7 +216,7 @@ export class StalkerPortalImportComponent {
const playlist: Playlist = {
...playlistFormValue,
portalUrl: transformedUrl,
portalUrl,
isFullStalkerPortal,
stalkerToken,
stalkerAccountInfo,
@@ -194,14 +230,6 @@ export class StalkerPortalImportComponent {
}
}
/**
* Checks if the URL is a full stalker portal URL that requires handshake authentication
* Pattern: example.com/stalker_portal/c or example.com/stalker_portal/...
*/
isFullStalkerPortalUrl(url: string): boolean {
return url.includes('/stalker_portal');
}
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
stalkerSerialNumber?: string;
stalkerDeviceId1?: string;
@@ -228,48 +256,4 @@ export class StalkerPortalImportComponent {
};
}
/**
* Transforms the portal URL to the correct API endpoint
* - Simple URL (example.com/c) -> example.com/portal.php
* - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php
*/
transformPortalUrl(url: string): string {
// Remove trailing slashes
url = url.replace(/\/+$/, '');
// Case 1: Simple URL ending with /c -> convert to /portal.php
if (url.endsWith('/c')) {
// Check if it's a full stalker portal URL
if (url.includes('/stalker_portal')) {
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
return url.replace(
/\/stalker_portal\/c$/,
'/stalker_portal/server/load.php'
);
}
// Simple URL: example.com/c -> example.com/portal.php
return url.replace(/\/c$/, '/portal.php');
}
// Case 2: Full stalker portal URL without /c at the end
if (
url.includes('/stalker_portal') &&
!url.includes('/server/load.php')
) {
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
if (url.endsWith('/stalker_portal')) {
return url + '/server/load.php';
}
// If it has other path segments after /stalker_portal, append server/load.php
if (!url.endsWith('/load.php')) {
return url.replace(
/\/stalker_portal(\/.*)?$/,
'/stalker_portal/server/load.php'
);
}
}
// Otherwise keep the provided url
return url;
}
}
@@ -19,10 +19,12 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import {
buildStalkerExternalPlaybackHeaders,
executeStalkerRequest,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
normalizeStalkerPlaybackCommand,
resolveStalkerPlaybackUrl,
StalkerPortalRepairService,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
@@ -71,6 +73,7 @@ export class StreamResolverService {
private readonly dataService = inject(DataService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly portalRepair = inject(StalkerPortalRepairService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 10000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
@@ -352,8 +355,18 @@ export class StreamResolverService {
};
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(
// Items opened from global collections can carry their own portal
// coordinates with no playlist row; only a playlist-backed request
// can go through the shared mode routing + lazy portal repair. When
// the row exists it wins over the item's snapshot of the portal URL
// (a repaired endpoint must beat a stale favorite).
if (playlist) {
response = await executeStalkerRequest<StalkerCreateLinkResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
params
);
@@ -367,14 +380,23 @@ export class StreamResolverService {
const rawCmd = response?.js?.cmd ?? '';
// Re-read the override AFTER the request: a lazy repair may have
// moved the endpoint during this very call, and both the relative
// `js.cmd` resolution and the playback header origin must follow
// the endpoint that actually answered.
const effectivePortalUrl = playlist
? (this.portalRepair.applyOverride(playlist).portalUrl ??
portalUrl)
: portalUrl;
return this.buildStalkerPlayback(item, playlist, {
macAddress,
portalUrl,
portalUrl: effectivePortalUrl,
// Shared normalizer from the Stalker store: strips the solution
// prefix and resolves relative `/media/...` or `?...` responses
// against the portal base instead of returning them verbatim.
streamUrl: resolveStalkerPlaybackUrl(
portalUrl,
effectivePortalUrl,
item.stalkerCmd ?? '',
rawCmd
),
@@ -1005,19 +1027,16 @@ export class StreamResolverService {
size: String(size),
};
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response = await this.stalkerSession.makeAuthenticatedRequest(
const response: StalkerEpgResponse =
await executeStalkerRequest<StalkerEpgResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
});
}
const epgData = Array.isArray(response?.js)
? response.js
@@ -4,6 +4,9 @@ export * from './lib/stalker-account-info.service';
export * from './lib/stalker-content-types';
export * from './lib/stalker-itv-cache.service';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-portal-discovery.service';
export * from './lib/stalker-portal-discovery.utils';
export * from './lib/stalker-portal-repair.service';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
export * from './lib/stalker-vod.utils';
@@ -6,6 +6,7 @@ import {
parseStalkerDate,
StalkerAccountInfoService,
} from './stalker-account-info.service';
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
import { StalkerSessionService } from './stalker-session.service';
describe('StalkerAccountInfoService', () => {
@@ -15,6 +16,11 @@ describe('StalkerAccountInfoService', () => {
refreshAccountProfile: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let portalRepair: {
applyOverride: jest.Mock;
shouldAttemptRepair: jest.Mock;
repairPortal: jest.Mock;
};
const portalPlaylist = {
_id: 'stalker-1',
@@ -39,11 +45,20 @@ describe('StalkerAccountInfoService', () => {
refreshAccountProfile: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
};
portalRepair = {
applyOverride: jest.fn((playlist) => playlist),
shouldAttemptRepair: jest.fn().mockReturnValue(false),
repairPortal: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
{ provide: DataService, useValue: dataService },
{ provide: StalkerSessionService, useValue: stalkerSession },
{
provide: StalkerPortalRepairService,
useValue: portalRepair,
},
],
});
@@ -86,6 +101,185 @@ describe('StalkerAccountInfoService', () => {
});
});
it('repairs the portal and retries when the profile request hits a repair trigger', async () => {
// The full-portal profile path bypasses executeStalkerRequest, so
// opening the dialog on a playlist with a stale endpoint must be
// able to repair it instead of just failing.
const notFound = new Error('HTTP Error 404: Not Found');
stalkerSession.refreshAccountProfile
.mockRejectedValueOnce(notFound)
.mockResolvedValueOnce({ login: 'user-1' });
const repaired = {
...fullPortalPlaylist,
portalUrl: 'http://portal.example/stalker_portal/server/load.php',
} as PlaylistMeta;
portalRepair.shouldAttemptRepair.mockReturnValue(true);
portalRepair.repairPortal.mockResolvedValue(repaired);
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
expect(portalRepair.repairPortal).toHaveBeenCalledWith(
fullPortalPlaylist
);
expect(
stalkerSession.refreshAccountProfile
).toHaveBeenLastCalledWith(
expect.objectContaining({ portalUrl: repaired.portalUrl })
);
expect(snapshot).toMatchObject({ login: 'user-1' });
});
it('re-routes to get_main_info when the repair proves the portal is simple', async () => {
// The playlist was wrongly marked full; discovery proves it is a
// token-free panel, so retrying the handshake profile would fail
// identically — the retry must use the simple-portal path.
stalkerSession.refreshAccountProfile.mockRejectedValue(
new Error('HTTP Error 404: Not Found')
);
dataService.sendIpcEvent.mockResolvedValue({
js: { login: 'panel-user' },
});
portalRepair.shouldAttemptRepair.mockReturnValue(true);
portalRepair.repairPortal.mockResolvedValue({
...fullPortalPlaylist,
portalUrl: 'http://portal.example/portal.php',
isFullStalkerPortal: false,
} as PlaylistMeta);
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
STALKER_REQUEST,
expect.objectContaining({
params: expect.objectContaining({ action: 'get_main_info' }),
})
);
expect(snapshot).toMatchObject({ login: 'panel-user' });
});
it('re-routes to the profile flow when a repair proves the portal is full', async () => {
// Legacy row marked simple: get_main_info goes through
// executeStalkerRequest, whose repair flips the mode to full and
// retries the same (wrong) action. The dialog must then switch to
// handshake + get_profile instead of showing nothing.
dataService.sendIpcEvent.mockResolvedValue({ js: null });
const repaired = {
...portalPlaylist,
portalUrl: 'http://portal.example/server/load.php',
isFullStalkerPortal: true,
} as PlaylistMeta;
portalRepair.applyOverride
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementation(() => repaired);
stalkerSession.refreshAccountProfile.mockResolvedValue({
login: 'full-user',
});
const snapshot = await service.fetchAccountInfo(portalPlaylist);
expect(stalkerSession.refreshAccountProfile).toHaveBeenCalledWith(
expect.objectContaining({ isFullStalkerPortal: true })
);
expect(snapshot).toMatchObject({ login: 'full-user' });
});
it('prefers the profile flow over a PARTIAL main-info answer after a mode repair', async () => {
// A bare login from get_main_info must not win over the profile
// flow once the repair proved the portal is full — expiry and
// tariff live only behind handshake + get_profile.
dataService.sendIpcEvent.mockResolvedValue({
js: { login: 'partial-user' },
});
const repaired = {
...portalPlaylist,
portalUrl: 'http://portal.example/server/load.php',
isFullStalkerPortal: true,
} as PlaylistMeta;
portalRepair.applyOverride
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementation(() => repaired);
stalkerSession.refreshAccountProfile.mockResolvedValue({
login: 'full-user',
expire_date: '1795000000',
tariff_plan_name: 'Premium',
});
const snapshot = await service.fetchAccountInfo(portalPlaylist);
expect(snapshot).toMatchObject({
login: 'full-user',
tariffPlanName: 'Premium',
});
});
it('keeps the partial main-info facts when the profile flow publishes nothing', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { login: 'partial-user' },
});
const repaired = {
...portalPlaylist,
portalUrl: 'http://portal.example/server/load.php',
isFullStalkerPortal: true,
} as PlaylistMeta;
// applyOverride runs twice before the repair lands (routing, then
// inside executeStalkerRequest); only afterwards does it report the
// repaired playlist.
portalRepair.applyOverride
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementation(() => repaired);
stalkerSession.refreshAccountProfile.mockResolvedValue(undefined);
const snapshot = await service.fetchAccountInfo(portalPlaylist);
expect(snapshot).toMatchObject({ login: 'partial-user' });
});
it('re-routes to the profile flow when the post-repair main-info retry rejects', async () => {
// A full installation that does not implement get_main_info answers
// the internal retry with 404 — the rejection must reach the
// repaired-mode check instead of failing the dialog.
dataService.sendIpcEvent.mockRejectedValue(
new Error('HTTP Error 404: Not Found')
);
const repaired = {
...portalPlaylist,
portalUrl: 'http://portal.example/server/load.php',
isFullStalkerPortal: true,
} as PlaylistMeta;
portalRepair.applyOverride
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementationOnce((value: PlaylistMeta) => value)
.mockImplementation(() => repaired);
stalkerSession.refreshAccountProfile.mockResolvedValue({
login: 'full-user',
});
const snapshot = await service.fetchAccountInfo(portalPlaylist);
expect(snapshot).toMatchObject({ login: 'full-user' });
});
it('rethrows a main-info failure when no repair changed the mode', async () => {
const boom = new Error('HTTP Error 404: Not Found');
dataService.sendIpcEvent.mockRejectedValue(boom);
await expect(service.fetchAccountInfo(portalPlaylist)).rejects.toBe(
boom
);
});
it('rethrows profile failures the repair declines to act on', async () => {
const boom = new Error('timeout of 15000ms exceeded');
stalkerSession.refreshAccountProfile.mockRejectedValue(boom);
await expect(
service.fetchAccountInfo(fullPortalPlaylist)
).rejects.toBe(boom);
expect(portalRepair.repairPortal).not.toHaveBeenCalled();
});
it('returns null when the full-portal profile has no account block', async () => {
stalkerSession.refreshAccountProfile.mockResolvedValue(undefined);
@@ -1,6 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
isFullStalkerPortalPlaylist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
import { StalkerSessionService } from './stalker-session.service';
import {
executeStalkerRequest,
@@ -63,6 +67,7 @@ interface StalkerMainInfoResponse {
export class StalkerAccountInfoService {
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly portalRepair = inject(StalkerPortalRepairService);
async fetchAccountInfo(
playlist: PlaylistMeta
@@ -71,15 +76,82 @@ export class StalkerAccountInfoService {
return null;
}
if (isFullStalkerPortalPlaylist(playlist)) {
return this.fetchViaProfile(playlist);
const effectivePlaylist = this.portalRepair.applyOverride(playlist);
if (isFullStalkerPortalPlaylist(effectivePlaylist)) {
return this.fetchViaProfile(effectivePlaylist);
}
return this.fetchViaMainInfo(playlist);
// A REJECTED main-info call must reach the same repaired-mode check
// as an empty or partial one: a repair can flip the portal to full
// mid-request, and a full installation that does not implement
// `get_main_info` answers the internal retry with 404.
let snapshot: StalkerAccountSnapshot | null = null;
let mainInfoError: unknown;
try {
snapshot = await this.fetchViaMainInfo(effectivePlaylist);
} catch (error) {
mainInfoError = error;
}
// `fetchViaMainInfo` runs through executeStalkerRequest, whose lazy
// repair retries the SAME action internally. If that repair proved
// the portal is actually a full one, `get_main_info` was the wrong
// call: canonical installations publish subscription details only
// through handshake + get_profile, so even a PARTIAL main-info
// answer (a bare login) must not win over the profile flow. Checked
// before accepting the snapshot, symmetric with the full→simple
// re-route in `fetchViaProfile`.
const repairedPlaylist = this.portalRepair.applyOverride(playlist);
if (
isFullStalkerPortalPlaylist(repairedPlaylist) &&
!isFullStalkerPortalPlaylist(effectivePlaylist)
) {
const profileSnapshot =
await this.fetchViaProfile(repairedPlaylist);
// Keep the partial main-info facts if the profile path itself
// publishes nothing — losing data to the re-route would be
// worse than the incomplete answer.
return profileSnapshot ?? snapshot;
}
// No mode change: a main-info failure is the caller's failure.
if (mainInfoError !== undefined) {
throw mainInfoError;
}
return snapshot;
}
private async fetchViaProfile(
playlist: PlaylistMeta
): Promise<StalkerAccountSnapshot | null> {
try {
return await this.requestProfileSnapshot(playlist);
} catch (error) {
// The profile path does not go through executeStalkerRequest,
// so wire the same lazy repair here: opening the account dialog
// on a playlist with a stale endpoint must be able to fix it
// instead of waiting for an unrelated catalog request.
if (!this.portalRepair.shouldAttemptRepair(playlist, error)) {
throw error;
}
const repaired = await this.portalRepair.repairPortal(playlist);
if (!repaired) {
throw error;
}
// Re-enter the MODE routing: a repair can prove the portal is a
// token-free panel, and retrying the handshake-based profile
// against it would fail exactly the same way.
return isFullStalkerPortalPlaylist(repaired)
? this.requestProfileSnapshot(repaired)
: this.fetchViaMainInfo(repaired);
}
}
private async requestProfileSnapshot(
playlist: PlaylistMeta
): Promise<StalkerAccountSnapshot | null> {
// Goes through the session service rather than calling
// authenticate() directly: it serializes with any in-flight
@@ -109,6 +181,7 @@ export class StalkerAccountInfoService {
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
{
@@ -146,26 +219,10 @@ export class StalkerAccountInfoService {
}
}
/**
* Whether a playlist should use the full `/stalker_portal/` flow.
*
* The persisted flag is authoritative when present, but a playlist
* restored from an older backup can carry `undefined` after the one-shot
* metadata migration has already run — fall back to the same URL rule
* that migration uses (`withExplicitLegacyStalkerPortalFlag` in
* PlaylistsService) rather than mislabelling it as a legacy panel.
*/
export function isFullStalkerPortalPlaylist(playlist: PlaylistMeta): boolean {
if (playlist.isFullStalkerPortal !== undefined) {
return Boolean(playlist.isFullStalkerPortal);
}
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
return (
portalUrl.includes('/stalker_portal') ||
portalUrl.includes('/server/load.php')
);
}
// The portal-mode predicate moved to `@iptvnator/shared/interfaces`
// (stalker-portal-mode.util) so every consumer shares one rule; re-exported
// here for existing importers.
export { isFullStalkerPortalPlaylist };
function normalizeSnapshot(
snapshot: StalkerAccountSnapshot
@@ -1,4 +1,5 @@
import {
normalizeStalkerIdentityValue,
normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity,
type Playlist,
type StalkerPortalIdentity,
@@ -13,6 +14,37 @@ export {
type StalkerPortalIdentity,
} from '@iptvnator/shared/interfaces';
/**
* Canonical fingerprint of WHO a portal session belongs to: the MAC plus
* every Stalker identity field, trim-normalized so blank and absent values
* are equivalent. The repair override, the once-per-config probe latch and
* the session token cache are all keyed/validated with this — a session
* negotiated for one fingerprint must never serve another.
*/
export function stalkerIdentityFingerprint(
playlist: Pick<
Playlist,
| 'macAddress'
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
| 'stalkerDeviceId2'
| 'stalkerSignature1'
| 'stalkerSignature2'
>
): string {
// JSON-encoded, not delimiter-joined: identity values are unrestricted
// strings, and an unescaped separator would let distinct tuples alias
// each other and bypass the identity invalidation.
return JSON.stringify([
normalizeStalkerIdentityValue(playlist.macAddress) ?? '',
normalizeStalkerIdentityValue(playlist.stalkerSerialNumber) ?? '',
normalizeStalkerIdentityValue(playlist.stalkerDeviceId1) ?? '',
normalizeStalkerIdentityValue(playlist.stalkerDeviceId2) ?? '',
normalizeStalkerIdentityValue(playlist.stalkerSignature1) ?? '',
normalizeStalkerIdentityValue(playlist.stalkerSignature2) ?? '',
]);
}
export function getStalkerPortalIdentityFromPlaylist(
playlist: Pick<
Playlist,
@@ -52,7 +52,9 @@ function pageOf(items: unknown[], totalItems: number, pageSize = 14) {
const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } };
async function flushMicrotasks(times = 5): Promise<void> {
// Depth 10: executeStalkerRequest routes through an extra async hop for
// the portal-repair pipeline, so page transitions settle a tick later.
async function flushMicrotasks(times = 10): Promise<void> {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
}
@@ -7,6 +7,7 @@ import {
StalkerItvLoadProgress,
loadFullItvChannelList,
} from './stalker-itv-channel-loader';
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerRequestDeps } from './stores/utils';
@@ -35,6 +36,7 @@ export class StalkerItvCacheService {
private readonly requestDeps: StalkerRequestDeps = {
dataService: inject(DataService),
stalkerSession: inject(StalkerSessionService),
portalRepair: inject(StalkerPortalRepairService),
};
/** Portal keys whose full channel list is loaded. */
@@ -0,0 +1,346 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
import { StalkerSessionService } from './stalker-session.service';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const MAC = '00:1A:79:AA:BB:CC';
describe('StalkerPortalDiscoveryService', () => {
let service: StalkerPortalDiscoveryService;
let sendIpcEvent: jest.Mock;
let authenticate: jest.Mock;
/** Maps probed endpoint URL → resolved value or rejection. */
function mockProbes(
handlers: Record<string, { resolve?: unknown; reject?: unknown }>
): void {
sendIpcEvent.mockImplementation((_event, payload) => {
const { url } = payload as { url: string };
const handler = handlers[url];
if (!handler) {
return Promise.reject(
new Error(`unexpected probe for ${url}`)
);
}
if ('reject' in handler) {
return Promise.reject(handler.reject);
}
return Promise.resolve(handler.resolve);
});
}
beforeEach(() => {
sendIpcEvent = jest.fn();
authenticate = jest.fn();
TestBed.configureTestingModule({
providers: [
{ provide: DataService, useValue: { sendIpcEvent } },
{ provide: StalkerSessionService, useValue: { authenticate } },
],
});
service = TestBed.inject(StalkerPortalDiscoveryService);
});
it('resolves a tolerant portal.php panel as a simple portal without authenticating', async () => {
mockProbes({
'http://panel.example/portal.php': {
resolve: { js: [{ id: '1', title: 'News' }] },
},
});
const outcome = await service.discover('http://panel.example/c', MAC);
expect(outcome).toEqual({
status: 'resolved',
portalUrl: 'http://panel.example/portal.php',
isFullStalkerPortal: false,
});
expect(authenticate).not.toHaveBeenCalled();
// The winning candidate ends discovery — no further probes.
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
});
it('falls through a 404 portal.php to server/load.php and classifies by handshake', async () => {
mockProbes({
'http://ministra.example/portal.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
'http://ministra.example/server/load.php': {
resolve: 'Authorization failed.',
},
});
authenticate.mockResolvedValue({
token: 'TOKEN1',
accountInfo: { login: 'user-1' },
});
const outcome = await service.discover(
'http://ministra.example/c',
MAC,
{ serialNumber: 'SN1' }
);
expect(outcome).toEqual({
status: 'resolved',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
token: 'TOKEN1',
accountInfo: { login: 'user-1' },
});
expect(authenticate).toHaveBeenCalledWith(
'http://ministra.example/server/load.php',
MAC,
{ serialNumber: 'SN1' }
);
});
it('resolves a pasted canonical URL in full mode without probing portal.php first', async () => {
mockProbes({
'http://ministra.example/server/load.php': {
resolve: 'Authorization failed.',
},
});
authenticate.mockResolvedValue({ token: 'TOKEN2' });
const outcome = await service.discover(
'http://ministra.example/server/load.php',
MAC
);
expect(outcome).toMatchObject({
status: 'resolved',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
});
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
});
it('classifies a token-enforcing portal.php panel as a full portal', async () => {
// Strict reseller panels exist; behavior beats the URL shape.
mockProbes({
'http://strict.example/portal.php': {
resolve: 'Authorization failed.',
},
});
authenticate.mockResolvedValue({ token: 'TOKEN3' });
const outcome = await service.discover('http://strict.example/c', MAC);
expect(outcome).toMatchObject({
status: 'resolved',
portalUrl: 'http://strict.example/portal.php',
isFullStalkerPortal: true,
});
});
it('reports auth-rejected when an endpoint demands auth we cannot complete', async () => {
mockProbes({
'http://ministra.example/portal.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
'http://ministra.example/server/load.php': {
resolve: 'Authorization failed.',
},
'http://ministra.example/stalker_portal/server/load.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
});
authenticate.mockRejectedValue(new Error('Profile error: blocked'));
const outcome = await service.discover(
'http://ministra.example/c',
MAC
);
expect(outcome).toMatchObject({
status: 'auth-rejected',
portalUrl: 'http://ministra.example/server/load.php',
});
});
it('treats an HTTP 401/403 probe answer as auth-required, not endpoint-absent', async () => {
// Non-standard middlewares answer 401 where the stock server sends
// HTTP 200 + plain text; skipping the candidate would abort imports
// for portals that previously authenticated directly.
mockProbes({
'http://gated.example/portal.php': {
reject: { message: 'HTTP Error 401: Unauthorized', status: 401 },
},
});
authenticate.mockResolvedValue({ token: 'TOKEN4' });
const outcome = await service.discover('http://gated.example/c', MAC);
expect(outcome).toMatchObject({
status: 'resolved',
portalUrl: 'http://gated.example/portal.php',
isFullStalkerPortal: true,
});
expect(authenticate).toHaveBeenCalledWith(
'http://gated.example/portal.php',
MAC,
{}
);
});
it('records a 401 candidate as auth-rejected when the handshake is refused', async () => {
mockProbes({
'http://gated.example/portal.php': {
reject: { message: 'HTTP Error 403: Forbidden', status: 403 },
},
'http://gated.example/server/load.php': {
reject: { message: 'HTTP Error 404: Not Found', status: 404 },
},
'http://gated.example/stalker_portal/server/load.php': {
reject: { message: 'HTTP Error 404: Not Found', status: 404 },
},
});
authenticate.mockRejectedValue(new Error('Handshake failed: No token received'));
const outcome = await service.discover('http://gated.example/c', MAC);
expect(outcome).toMatchObject({
status: 'auth-rejected',
portalUrl: 'http://gated.example/portal.php',
});
});
it('rejects a candidate whose get_profile answers a structured denial', async () => {
// The handshake can hand out a token whose profile call still
// denies; accepting it would persist an unusable endpoint and skip
// the healthy sibling.
mockProbes({
'http://mixed.example/portal.php': {
resolve: 'Authorization failed.',
},
'http://mixed.example/server/load.php': {
resolve: { js: [{ id: '1' }] },
},
});
authenticate.mockResolvedValue({
token: 'TOKEN-BAD',
profileResponse: { js: { error: 'Invalid token' } },
});
const outcome = await service.discover('http://mixed.example/c', MAC);
// Discovery moved on and resolved the healthy sibling instead.
expect(outcome).toEqual({
status: 'resolved',
portalUrl: 'http://mixed.example/server/load.php',
isFullStalkerPortal: false,
});
});
it('reports unreachable when every candidate 404s', async () => {
mockProbes({
'http://empty.example/portal.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
'http://empty.example/server/load.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
'http://empty.example/stalker_portal/server/load.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
});
const outcome = await service.discover('http://empty.example/c', MAC);
expect(outcome).toEqual({ status: 'unreachable' });
});
it('stops probing after a network-level failure — all candidates share the host', async () => {
// Post-IPC, a network failure carries no resolvable HTTP status:
// ipcRenderer strips the object shape and the message has no
// "HTTP Error NNN" marker.
mockProbes({
'http://down.example/portal.php': {
reject: new Error(
"Error invoking remote method 'STALKER_REQUEST': connect ECONNREFUSED"
),
},
});
const outcome = await service.discover('http://down.example/c', MAC);
expect(outcome).toEqual({ status: 'unreachable' });
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
});
it('keeps probing past a candidate timeout — one handler can hang while siblings work', async () => {
mockProbes({
'http://slow.example/portal.php': {
reject: new Error(
"Error invoking remote method 'STALKER_REQUEST': timeout of 15000ms exceeded"
),
},
'http://slow.example/server/load.php': {
resolve: { js: [] },
},
});
const outcome = await service.discover('http://slow.example/c', MAC);
expect(outcome).toEqual({
status: 'resolved',
portalUrl: 'http://slow.example/server/load.php',
isFullStalkerPortal: false,
});
});
it('keeps probing past an endpoint-specific 5xx — the host answered', async () => {
// One broken handler (a dead portal.php returning 500) must not
// hide a healthy sibling endpoint on the same host.
mockProbes({
'http://flaky.example/portal.php': {
reject: {
message: 'HTTP Error 500: Internal Server Error',
status: 500,
},
},
'http://flaky.example/server/load.php': {
resolve: 'Authorization failed.',
},
});
authenticate.mockResolvedValue({ token: 'TOKEN5' });
const outcome = await service.discover('http://flaky.example/c', MAC);
expect(outcome).toMatchObject({
status: 'resolved',
portalUrl: 'http://flaky.example/server/load.php',
isFullStalkerPortal: true,
});
});
it('skips endpoints that answer with something that is not a portal', async () => {
mockProbes({
'http://mixed.example/portal.php': {
resolve: '<html>It works!</html>',
},
'http://mixed.example/server/load.php': {
resolve: { js: [] },
},
});
const outcome = await service.discover('http://mixed.example/c', MAC);
expect(outcome).toEqual({
status: 'resolved',
portalUrl: 'http://mixed.example/server/load.php',
isFullStalkerPortal: false,
});
});
});
@@ -0,0 +1,252 @@
import { Injectable, inject } from '@angular/core';
import { DataService } from '@iptvnator/services';
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerProfileResponse,
StalkerSessionService,
} from './stalker-session.service';
import { type StalkerPortalIdentity } from './stalker-identity.utils';
import {
buildStalkerEndpointCandidates,
classifyStalkerProbeResponse,
getStalkerRequestErrorStatus,
isStalkerAuthFailureResponse,
isStalkerProbeTimeout,
} from './stalker-portal-discovery.utils';
/** A candidate endpoint answered and its auth behavior was observed. */
export interface StalkerPortalEndpointResolution {
status: 'resolved';
/** The endpoint that actually answered content requests. */
portalUrl: string;
/** Observed behavior: true when the endpoint enforces the Bearer token. */
isFullStalkerPortal: boolean;
/** Session token from the classification handshake (full portals only). */
token?: string;
/** Account block from the classification `get_profile` (full portals only). */
accountInfo?: StalkerProfileResponse['js']['account_info'];
}
/**
* An endpoint exists and demands authentication, but the handshake/profile
* flow was refused — wrong MAC, blocked account, or a panel we cannot
* authenticate against. Nothing may be persisted from this outcome.
*/
export interface StalkerPortalDiscoveryRejection {
status: 'auth-rejected';
portalUrl: string;
error?: unknown;
}
/** No candidate answered like a Stalker portal (host down or not a portal). */
export interface StalkerPortalDiscoveryUnreachable {
status: 'unreachable';
}
export type StalkerPortalDiscoveryOutcome =
| StalkerPortalEndpointResolution
| StalkerPortalDiscoveryRejection
| StalkerPortalDiscoveryUnreachable;
/** Per-request guard so a hanging host cannot stall discovery forever. */
const PROBE_TIMEOUT_MS = 20_000;
/** authenticate() is two sequential requests; give it a matching budget. */
const AUTH_TIMEOUT_MS = 45_000;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('Stalker portal probe timed out')),
timeoutMs
);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
/**
* Resolves which API endpoint a Stalker portal actually answers on and
* whether it enforces the full auth lifecycle — by probing, not by URL
* shape. Used at import time and by the lazy repair of previously
* misclassified playlists.
*/
@Injectable({ providedIn: 'root' })
export class StalkerPortalDiscoveryService {
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly logger = createLogger('StalkerPortalDiscovery');
/**
* Probes candidate endpoints in order and classifies the first one that
* answers. Per candidate: a token-less content request that returns real
* data proves a token-free panel; the middleware's plain-text auth
* failure proves the endpoint exists and enforces the token, which is
* then confirmed by attempting the real handshake + `get_profile` flow.
*/
async discover(
rawUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<StalkerPortalDiscoveryOutcome> {
const candidates = buildStalkerEndpointCandidates(rawUrl);
let authRejection: StalkerPortalDiscoveryRejection | null = null;
for (const candidate of candidates) {
let probeResponse: unknown;
try {
probeResponse = await this.probeContent(candidate, macAddress);
} catch (error) {
const status = getStalkerRequestErrorStatus(error);
if (status === 401 || status === 403) {
// The endpoint exists but sits behind an HTTP auth gate —
// non-standard middlewares answer 401/403 where the stock
// server answers 200 + plain text. Attempt the real
// handshake instead of skipping a valid candidate.
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
);
if (outcome.status === 'resolved') {
return outcome;
}
authRejection = authRejection ?? outcome;
continue;
}
if (status !== undefined) {
// Any resolvable HTTP status proves the HOST answered:
// 4xx means this endpoint is absent, and a 5xx here can
// be one broken handler (a dead /portal.php) while a
// sibling candidate works — keep probing either way.
continue;
}
if (isStalkerProbeTimeout(error)) {
// A timeout can also be one hanging handler with healthy
// siblings; each further candidate stays bounded by its
// own probe budget.
this.logger.warn(
'Stalker portal probe timed out; trying the next candidate'
);
continue;
}
// Connection-level failure (refused, unresolvable host):
// every candidate lives on the same host, so further probing
// cannot succeed either.
this.logger.warn(
'Stalker portal probe failed at network level; stopping discovery'
);
return authRejection ?? { status: 'unreachable' };
}
switch (classifyStalkerProbeResponse(probeResponse)) {
case 'data':
return {
status: 'resolved',
portalUrl: candidate,
isFullStalkerPortal: false,
};
case 'auth-required': {
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
);
if (outcome.status === 'resolved') {
return outcome;
}
// The endpoint is real but refused our credentials;
// remember the first such endpoint in case no later
// candidate resolves.
authRejection = authRejection ?? outcome;
continue;
}
case 'not-a-portal':
continue;
}
}
return authRejection ?? { status: 'unreachable' };
}
/**
* Confirms a token-enforcing endpoint by running the real handshake +
* `get_profile` flow against it.
*/
private async confirmFullPortal(
candidate: string,
macAddress: string,
identity: StalkerPortalIdentity
): Promise<
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
> {
try {
const auth = await withTimeout(
this.stalkerSession.authenticate(
candidate,
macAddress,
identity
),
AUTH_TIMEOUT_MS
);
// A handshake can hand out a token whose `get_profile` still
// answers a structured denial (`{js:{error:'Invalid token'}}`);
// `authenticate()` only inspects `msg`/`block_msg`, so reporting
// `resolved` here would persist an unusable endpoint and stop
// before a healthy sibling is probed.
if (isStalkerAuthFailureResponse(auth.profileResponse)) {
return {
status: 'auth-rejected',
portalUrl: candidate,
error: auth.profileResponse,
};
}
return {
status: 'resolved',
portalUrl: candidate,
isFullStalkerPortal: true,
token: auth.token,
accountInfo: auth.accountInfo,
};
} catch (error) {
return {
status: 'auth-rejected',
portalUrl: candidate,
error,
};
}
}
/**
* Token-less, read-only content request (`itv/get_genres`) — the
* cheapest action every Stalker-compatible panel implements and the
* canonical middleware gates behind the Bearer token.
*/
private probeContent(url: string, macAddress: string): Promise<unknown> {
return withTimeout(
Promise.resolve(
this.dataService.sendIpcEvent<unknown>(STALKER_REQUEST, {
url,
macAddress,
params: {
type: 'itv',
action: 'get_genres',
JsHttpRequest: '1-xml',
},
// Probing absent endpoints fails BY DESIGN — the
// transport services skip their error snackbar for us.
silent: true,
})
),
PROBE_TIMEOUT_MS
);
}
}
@@ -0,0 +1,385 @@
import {
buildStalkerEndpointCandidates,
classifyStalkerProbeResponse,
getStalkerRequestErrorStatus,
isStalkerAuthFailureBody,
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
} from './stalker-portal-discovery.utils';
describe('buildStalkerEndpointCandidates', () => {
it('probes the standard order for a /c URL users copy from the browser', () => {
expect(
buildStalkerEndpointCandidates('http://portal.example/c')
).toEqual([
'http://portal.example/portal.php',
'http://portal.example/server/load.php',
'http://portal.example/stalker_portal/server/load.php',
]);
});
it('probes the same order for a bare host', () => {
expect(buildStalkerEndpointCandidates('http://portal.example')).toEqual(
[
'http://portal.example/portal.php',
'http://portal.example/server/load.php',
'http://portal.example/stalker_portal/server/load.php',
]
);
});
it('strips trailing slashes before deriving candidates', () => {
expect(
buildStalkerEndpointCandidates('http://portal.example/c///')
).toEqual([
'http://portal.example/portal.php',
'http://portal.example/server/load.php',
'http://portal.example/stalker_portal/server/load.php',
]);
});
it('gives an explicitly pasted .php endpoint the first shot', () => {
expect(
buildStalkerEndpointCandidates(
'http://portal.example/server/load.php'
)
).toEqual([
'http://portal.example/server/load.php',
'http://portal.example/portal.php',
'http://portal.example/stalker_portal/server/load.php',
]);
});
it('keeps a nonstandard pasted endpoint as the first candidate', () => {
expect(
buildStalkerEndpointCandidates('http://portal.example/cp/portal.php')
).toEqual([
'http://portal.example/cp/portal.php',
'http://portal.example/cp/server/load.php',
'http://portal.example/cp/stalker_portal/server/load.php',
]);
});
it('derives standard fallbacks from a nonstandard endpoint\'s directory', () => {
// The pasted endpoint keeps the first shot, but recovery candidates
// must be its SIBLINGS — not paths appended to the file itself.
expect(
buildStalkerEndpointCandidates('http://portal.example/cp/api.php')
).toEqual([
'http://portal.example/cp/api.php',
'http://portal.example/cp/portal.php',
'http://portal.example/cp/server/load.php',
'http://portal.example/cp/stalker_portal/server/load.php',
]);
});
it('keeps a real installation directory named c', () => {
// `/tenant/c/portal.php` means the installation lives in `/tenant/c`
// — the `/c` landing-page rewrite must not strip it, or the
// siblings would be probed one level too high.
expect(
buildStalkerEndpointCandidates(
'http://portal.example/tenant/c/portal.php'
)
).toEqual([
'http://portal.example/tenant/c/portal.php',
'http://portal.example/tenant/c/server/load.php',
'http://portal.example/tenant/c/stalker_portal/server/load.php',
]);
});
it('never nests stalker_portal twice for a /stalker_portal/c URL', () => {
expect(
buildStalkerEndpointCandidates(
'http://portal.example/stalker_portal/c'
)
).toEqual([
'http://portal.example/stalker_portal/portal.php',
'http://portal.example/stalker_portal/server/load.php',
]);
});
it('deduplicates the pasted canonical stalker_portal endpoint', () => {
expect(
buildStalkerEndpointCandidates(
'http://portal.example/stalker_portal/server/load.php'
)
).toEqual([
'http://portal.example/stalker_portal/server/load.php',
'http://portal.example/stalker_portal/portal.php',
]);
});
it('derives candidates from the pathname when the URL carries a query or fragment', () => {
// Suffix matching on the raw string would keep `/c` and append the
// endpoints inside the query — every probe would still hit /c.
expect(
buildStalkerEndpointCandidates('http://portal.example/c?key=value')
).toEqual([
'http://portal.example/portal.php',
'http://portal.example/server/load.php',
'http://portal.example/stalker_portal/server/load.php',
]);
expect(
buildStalkerEndpointCandidates(
'http://portal.example:8080/portal.php?sn=1#frag'
)
).toEqual([
'http://portal.example:8080/portal.php',
'http://portal.example:8080/server/load.php',
'http://portal.example:8080/stalker_portal/server/load.php',
]);
});
it('returns no candidates for empty or unparseable URLs', () => {
expect(buildStalkerEndpointCandidates(' ')).toEqual([]);
expect(buildStalkerEndpointCandidates('not-a-url')).toEqual([]);
});
});
describe('normalizeStalkerPortalInputUrl', () => {
it('reduces a URL to origin + pathname', () => {
expect(
normalizeStalkerPortalInputUrl('http://host.example/c?key=value#f')
).toBe('http://host.example/c');
expect(
normalizeStalkerPortalInputUrl(' http://host.example:8080/c/ ')
).toBe('http://host.example:8080/c');
});
it('feeds the legacy fallback transform a rewritable path', () => {
// The offline-import fallback runs the legacy /c → portal.php
// rewrite on this form; unnormalized input would keep the /c page.
expect(
legacyTransformStalkerPortalUrl(
normalizeStalkerPortalInputUrl(
'http://host.example/c?key=value'
) ?? ''
)
).toBe('http://host.example/portal.php');
});
it('returns null for unparseable input', () => {
expect(normalizeStalkerPortalInputUrl('not-a-url')).toBeNull();
expect(normalizeStalkerPortalInputUrl(' ')).toBeNull();
});
it('preserves the accepted URL authority instead of rebuilding from origin', () => {
// Basic-auth credentials must not be silently dropped…
expect(
normalizeStalkerPortalInputUrl(
'https://user:pass@host.example/c?key=value'
)
).toBe('https://user:pass@host.example/c');
// …and file: URLs (origin "null") must stay parseable rather than
// becoming "null/tmp/c" and throwing in the candidate builder.
expect(normalizeStalkerPortalInputUrl('file:///tmp/c')).toBe(
'file:///tmp/c'
);
expect(buildStalkerEndpointCandidates('file:///tmp/c')).toEqual([
'file:///tmp/portal.php',
'file:///tmp/server/load.php',
'file:///tmp/stalker_portal/server/load.php',
]);
expect(
buildStalkerEndpointCandidates('https://user:pass@host.example/c')
).toEqual([
'https://user:pass@host.example/portal.php',
'https://user:pass@host.example/server/load.php',
'https://user:pass@host.example/stalker_portal/server/load.php',
]);
});
});
describe('isStalkerAuthFailureBody', () => {
it.each([
'Authorization failed.',
'Authorization failed. 75',
'Access denied.',
'Unauthorized request.',
' Authorization failed. ',
])('recognizes the middleware body %j', (body) => {
expect(isStalkerAuthFailureBody(body)).toBe(true);
});
it('rejects long HTML pages that merely mention the phrase', () => {
const page = `<html><head><title>Site</title></head><body>${'x'.repeat(
300
)} access denied ${'y'.repeat(100)}</body></html>`;
expect(isStalkerAuthFailureBody(page)).toBe(false);
});
it('rejects non-string and empty responses', () => {
expect(isStalkerAuthFailureBody({ js: [] })).toBe(false);
expect(isStalkerAuthFailureBody(undefined)).toBe(false);
expect(isStalkerAuthFailureBody(null)).toBe(false);
expect(isStalkerAuthFailureBody('')).toBe(false);
expect(isStalkerAuthFailureBody('OK')).toBe(false);
});
});
describe('isStalkerAuthFailureResponse', () => {
it('recognizes both the plain-text body and the JSON envelope forms', () => {
expect(isStalkerAuthFailureResponse('Authorization failed.')).toBe(
true
);
expect(
isStalkerAuthFailureResponse({
js: { error: 'Authorization failed' },
})
).toBe(true);
expect(
isStalkerAuthFailureResponse({ js: { msg: 'Access denied.' } })
).toBe(true);
});
it('recognizes the wider structured-field phrases the session service accepts', () => {
expect(
isStalkerAuthFailureResponse({ js: { error: 'Invalid token' } })
).toBe(true);
expect(
isStalkerAuthFailureResponse({ js: { error: 'Auth failed' } })
).toBe(true);
expect(
isStalkerAuthFailureResponse({ js: { msg: 'unauthorized' } })
).toBe(true);
});
it('does not flag ordinary data or unrelated js errors', () => {
expect(isStalkerAuthFailureResponse({ js: { data: [] } })).toBe(false);
expect(
isStalkerAuthFailureResponse({
js: { error: 'Unknown action: get_genres' },
})
).toBe(false);
expect(isStalkerAuthFailureResponse(undefined)).toBe(false);
});
});
describe('isStalkerAuthFailureMessage', () => {
it('matches the wide phrase set our own auth layer produces', () => {
expect(
isStalkerAuthFailureMessage('Profile error: Invalid token')
).toBe(true);
expect(isStalkerAuthFailureMessage('Profile error: Auth failed')).toBe(
true
);
expect(
isStalkerAuthFailureMessage('Profile error: Access denied.')
).toBe(true);
});
it('stays narrower than a free-form body check for unrelated text', () => {
expect(isStalkerAuthFailureMessage('nothing_to_play')).toBe(false);
expect(isStalkerAuthFailureMessage('timeout of 15000ms exceeded')).toBe(
false
);
expect(isStalkerAuthFailureMessage(undefined)).toBe(false);
});
it('is wider than the plain-text BODY matcher on purpose', () => {
// A portal BODY saying "Invalid token" is not one of the three
// middleware phrases; a controlled Error message is.
expect(isStalkerAuthFailureBody('Invalid token')).toBe(false);
expect(isStalkerAuthFailureMessage('Invalid token')).toBe(true);
});
});
describe('classifyStalkerProbeResponse', () => {
it('classifies a js envelope as data', () => {
expect(classifyStalkerProbeResponse({ js: [] })).toBe('data');
expect(classifyStalkerProbeResponse({ js: { data: [] } })).toBe('data');
});
it('classifies the plain-text auth failure as auth-required', () => {
expect(classifyStalkerProbeResponse('Authorization failed.')).toBe(
'auth-required'
);
});
it('classifies the JSON-envelope auth failure as auth-required, not data', () => {
// Some panels answer HTTP 200 + {js:{error:"Authorization failed"}}
// instead of the plain-text body; treating it as data would persist
// the portal as token-free with no repair trigger ever firing.
expect(
classifyStalkerProbeResponse({
js: { error: 'Authorization failed' },
})
).toBe('auth-required');
});
it('requires a real get_genres data shape, not a bare js key', () => {
// A 200 error envelope must not end discovery on a broken
// candidate — the healthy sibling would never be probed.
expect(
classifyStalkerProbeResponse({ js: { error: 'Unknown action' } })
).toBe('not-a-portal');
expect(classifyStalkerProbeResponse({ js: false })).toBe(
'not-a-portal'
);
expect(classifyStalkerProbeResponse({ js: null })).toBe(
'not-a-portal'
);
expect(classifyStalkerProbeResponse({ js: {} })).toBe('not-a-portal');
});
it('classifies anything else as not-a-portal', () => {
expect(classifyStalkerProbeResponse('<html>welcome</html>')).toBe(
'not-a-portal'
);
expect(classifyStalkerProbeResponse(undefined)).toBe('not-a-portal');
expect(classifyStalkerProbeResponse({ payload: 1 })).toBe(
'not-a-portal'
);
});
});
describe('getStalkerRequestErrorStatus', () => {
it('reads the status the Electron transport throws for HTTP errors', () => {
expect(
getStalkerRequestErrorStatus({
message: 'HTTP Error 404: Not Found',
status: 404,
})
).toBe(404);
});
it('parses the status out of the IPC-wrapped message — invoke strips custom properties', () => {
expect(
getStalkerRequestErrorStatus(
new Error(
"Error invoking remote method 'STALKER_REQUEST': HTTP Error 404: Not Found"
)
)
).toBe(404);
// HTTP/2 has no reason phrases; the code alone must be enough.
expect(
getStalkerRequestErrorStatus(new Error('HTTP Error 404: '))
).toBe(404);
});
it('returns undefined for plain errors', () => {
expect(getStalkerRequestErrorStatus(new Error('boom'))).toBeUndefined();
expect(getStalkerRequestErrorStatus(undefined)).toBeUndefined();
expect(getStalkerRequestErrorStatus({ status: '404' })).toBeUndefined();
});
});
describe('legacyTransformStalkerPortalUrl', () => {
it('keeps the historical rewrites for the unreachable-host fallback', () => {
expect(legacyTransformStalkerPortalUrl('http://x.example/c')).toBe(
'http://x.example/portal.php'
);
expect(
legacyTransformStalkerPortalUrl('http://x.example/stalker_portal/c')
).toBe('http://x.example/stalker_portal/server/load.php');
expect(
legacyTransformStalkerPortalUrl('http://x.example/stalker_portal')
).toBe('http://x.example/stalker_portal/server/load.php');
expect(
legacyTransformStalkerPortalUrl('http://x.example/portal.php')
).toBe('http://x.example/portal.php');
});
});
@@ -0,0 +1,324 @@
/**
* Pure helpers for Stalker portal endpoint discovery.
*
* The portal API endpoint cannot be derived reliably from the URL a user
* pastes: `portal.php` is a reseller-panel alias that official
* Stalker/Ministra never serves, while genuine installations answer at
* `<base>/server/load.php` (optionally under `/stalker_portal`). Discovery
* therefore probes concrete candidates and classifies each endpoint by how
* it responds, instead of guessing from the URL shape (#850, #686, #755).
*/
/**
* Candidate API endpoints for a pasted portal URL, in probe order.
*
* An explicit `.php` endpoint pasted by the user (or persisted by a previous
* import) always gets the first shot — nonstandard panel paths exist in the
* wild and must not lose to the standard candidates. After that the order is
* `portal.php` → `server/load.php` → `stalker_portal/server/load.php`, so
* reseller panels resolve exactly as they did before discovery existed.
*/
/**
* Reduces a pasted portal URL to `origin + pathname` (no query, no
* fragment, no trailing slashes). Suffix logic anywhere in discovery must
* run on this form: string-suffix matching on the raw URL would bolt
* endpoint rewrites onto the query instead of the path. Returns null for
* input the URL parser rejects.
*/
export function normalizeStalkerPortalInputUrl(rawUrl: string): string | null {
const trimmed = rawUrl.trim();
if (!trimmed) {
return null;
}
try {
// Mutate the parsed URL instead of rebuilding from `origin`:
// origin-reconstruction destroys authority information the import
// validator accepts — file: URLs have origin "null" and basic-auth
// credentials (user:pass@host) would be silently dropped.
const parsed = new URL(trimmed);
parsed.search = '';
parsed.hash = '';
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
return parsed.href;
} catch {
return null;
}
}
export function buildStalkerEndpointCandidates(rawUrl: string): string[] {
// Queries and fragments are dropped from every candidate — the Stalker
// API endpoints take their parameters per request, and a stored query
// would collide with the transport's own query building.
const normalized = normalizeStalkerPortalInputUrl(rawUrl);
if (normalized === null) {
return [];
}
const parsed = new URL(normalized);
const path = parsed.pathname.replace(/\/+$/, '');
// Candidates swap only the PATH: scheme, credentials, host and port of
// the accepted URL are preserved verbatim.
const candidateFrom = (candidatePath: string): string => {
const candidate = new URL(parsed.href);
candidate.pathname = candidatePath;
return candidate.href;
};
const candidates: string[] = [];
if (/\.php$/i.test(path)) {
candidates.push(candidateFrom(path));
}
const base = /\.php$/i.test(path)
? // An endpoint file was pasted: strip only the FILE part. The `/c`
// landing-page rewrite must not run here — a real installation
// directory named `c` (`/tenant/c/portal.php`) would otherwise be
// stripped too and the siblings probed one level too high.
path
.replace(/\/portal\.php$/i, '')
.replace(/\/server\/load\.php$/i, '')
// A nonstandard pasted endpoint (…/cp/api.php) keeps its
// first-shot candidate above, but the standard fallbacks must
// be its SIBLINGS — the directory, not the file.
.replace(/\/[^/]*\.php$/i, '')
: // The `/c` landing page users copy from the browser is not part
// of the API path.
path.replace(/\/c$/i, '');
candidates.push(candidateFrom(`${base}/portal.php`));
candidates.push(candidateFrom(`${base}/server/load.php`));
// `<base>/server/load.php` already IS the canonical form when the base
// ends in /stalker_portal — nesting it again would probe a path no
// server has.
if (!/\/stalker_portal(\/|$)/i.test(base)) {
candidates.push(candidateFrom(`${base}/stalker_portal/server/load.php`));
}
return [...new Set(candidates)];
}
/**
* The stock Stalker middleware answers auth failures with HTTP 200 and a
* bare plain-text body — never a 401/403. These are the three exact strings
* it emits (sometimes with a trailing numeric counter).
*/
const STALKER_AUTH_FAILURE_PATTERNS = [
/authorization\s+failed/i,
/access\s+denied/i,
/unauthorized\s+request/i,
];
/**
* Whether a portal response body is one of the middleware's plain-text auth
* failures. The length cap keeps an arbitrary HTML error page that merely
* mentions "access denied" from being mistaken for the middleware's bare
* phrase.
*/
export function isStalkerAuthFailureBody(response: unknown): boolean {
if (typeof response !== 'string') {
return false;
}
const body = response.trim();
if (body.length === 0 || body.length > 200) {
return false;
}
return STALKER_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(body));
}
/**
* Whether a portal response is an authorization failure in EITHER wire
* shape: the middleware's plain-text body, or the JSON envelope some panels
* answer instead (`{ js: { error: "Authorization failed" } }` /
* `{ js: { msg: … } }` — the same forms
* `StalkerSessionService.isAuthorizationError()` recognizes). Classification
* and the lazy-repair trigger must use this, not the string-only primitive:
* a JSON-failing panel would otherwise be persisted as token-free and never
* repaired.
*/
export function isStalkerAuthFailureResponse(response: unknown): boolean {
if (isStalkerAuthFailureBody(response)) {
return true;
}
if (
response === null ||
typeof response !== 'object' ||
!('js' in (response as Record<string, unknown>))
) {
return false;
}
const js = (response as { js?: unknown }).js;
if (js === null || typeof js !== 'object') {
return false;
}
const { error, msg } = js as { error?: unknown; msg?: unknown };
return [error, msg].some(
(value) =>
typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value)
);
}
/**
* Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg`
* fields. Deliberately wider than the plain-text body patterns (which stay
* narrow to avoid matching arbitrary HTML pages): these are the same forms
* `StalkerSessionService.isAuthorizationError()` recognizes — panels answer
* "Invalid token", "Auth failed" or bare "unauthorized" here.
*/
const STALKER_JSON_AUTH_FAILURE_PATTERNS = [
...STALKER_AUTH_FAILURE_PATTERNS,
/auth\s+failed/i,
/invalid\s+token/i,
/\bunauthorized\b/i,
/authorization/i,
];
/**
* Whether an ERROR MESSAGE reports an authorization failure. Uses the wide
* phrase set (including `Invalid token` / `Auth failed`) because the input
* is a controlled string produced by our own auth layer — e.g.
* `Error('Profile error: Invalid token')` — not an arbitrary portal body,
* where the same breadth would false-positive on HTML pages.
*/
export function isStalkerAuthFailureMessage(message: unknown): boolean {
return (
typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
);
}
function isStalkerJsonAuthFailurePhrase(value: string): boolean {
const phrase = value.trim();
if (phrase.length === 0 || phrase.length > 200) {
return false;
}
return STALKER_JSON_AUTH_FAILURE_PATTERNS.some((pattern) =>
pattern.test(phrase)
);
}
export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal';
/**
* Classifies what a token-less content request got back from a candidate
* endpoint: real JSON data (token-free panel), the middleware's plain-text
* auth failure (endpoint exists and enforces the token), or something that
* is not a Stalker portal at all.
*/
export function classifyStalkerProbeResponse(
response: unknown
): StalkerProbeClassification {
if (isStalkerAuthFailureResponse(response)) {
return 'auth-required';
}
if (response !== null && typeof response === 'object') {
const js = (response as { js?: unknown }).js;
// The probe asks for `itv/get_genres`, whose success shape is a
// list (or a `{data: [...]}` envelope). A bare `js` key is NOT
// enough: panels answer HTTP 200 with `{js: {error: "Unknown
// action"}}` or `{js: false}`, and accepting those would end
// discovery on a broken candidate and persist an empty catalog.
if (Array.isArray(js)) {
return 'data';
}
if (js !== null && typeof js === 'object') {
const { data, error } = js as { data?: unknown; error?: unknown };
if (Array.isArray(data) && error === undefined) {
return 'data';
}
}
}
return 'not-a-portal';
}
/**
* HTTP status carried by a failed Stalker transport call, when there is one.
* The Electron handler rejects with an Error whose message is
* `HTTP Error <code>: <statusText>` (network-level failures map to 500) —
* but `ipcRenderer.invoke` strips every custom property from a rejected
* value and re-wraps the message, so in the renderer the numeric `status`
* field usually does NOT survive and the code must be parsed back out of
* the message text.
*/
export function getStalkerRequestErrorStatus(
error: unknown
): number | undefined {
if (error === null || typeof error !== 'object') {
return undefined;
}
if ('status' in error) {
const status = (error as { status?: unknown }).status;
if (typeof status === 'number') {
return status;
}
}
if ('message' in error) {
const match = /HTTP Error (\d{3})\b/.exec(
String((error as { message?: unknown }).message ?? '')
);
if (match) {
return Number(match[1]);
}
}
return undefined;
}
/**
* Whether a status-less probe failure is a TIMEOUT (renderer probe budget,
* axios request timeout, ETIMEDOUT). A timeout can be one hanging handler
* while sibling endpoints answer fine, so discovery continues past it;
* connection-level failures (ECONNREFUSED, ENOTFOUND, …) still stop the
* loop — they prove the HOST is unreachable for every candidate.
*/
export function isStalkerProbeTimeout(error: unknown): boolean {
if (error === null || typeof error !== 'object' || !('message' in error)) {
return false;
}
const message = String((error as { message?: unknown }).message ?? '');
return /timed out|timeout of \d+\s*ms|ETIMEDOUT/i.test(message);
}
/**
* The pre-discovery URL rewrite (`…/c` → `portal.php`, `…/stalker_portal/c`
* → `…/stalker_portal/server/load.php`). Kept ONLY as the fallback for
* imports where no candidate could be probed (host unreachable); discovery
* results always win over this guess.
*/
export function legacyTransformStalkerPortalUrl(url: string): string {
url = url.replace(/\/+$/, '');
if (url.endsWith('/c')) {
if (url.includes('/stalker_portal')) {
return url.replace(
/\/stalker_portal\/c$/,
'/stalker_portal/server/load.php'
);
}
return url.replace(/\/c$/, '/portal.php');
}
if (url.includes('/stalker_portal') && !url.includes('/server/load.php')) {
if (url.endsWith('/stalker_portal')) {
return url + '/server/load.php';
}
if (!url.endsWith('/load.php')) {
return url.replace(
/\/stalker_portal(\/.*)?$/,
'/stalker_portal/server/load.php'
);
}
}
return url;
}
@@ -0,0 +1,671 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import type { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
import { StalkerSessionService } from './stalker-session.service';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const MISCLASSIFIED = {
_id: 'portal-1',
title: 'Canonical Ministra',
portalUrl: 'http://ministra.example/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: false,
} as PlaylistMeta;
describe('StalkerPortalRepairService', () => {
let service: StalkerPortalRepairService;
let discover: jest.Mock;
let transformPlaylistMeta: jest.Mock;
/** What the persisted row looks like when the repair re-verifies it. */
let persistedRow: Playlist | undefined;
/** The row the atomic transform actually wrote, if any. */
let writtenRow: Playlist | null;
/** When set, the atomic write fails AFTER the transform verified. */
let persistError: Error | null;
let setCachedToken: jest.Mock;
let clearCachedToken: jest.Mock;
let refreshActiveWatchdogPlaylist: jest.Mock;
beforeEach(() => {
discover = jest.fn();
persistedRow = MISCLASSIFIED as Playlist;
writtenRow = null;
persistError = null;
// Mirrors PlaylistsService.transformPlaylistMeta semantics: the
// transform runs on the current row inside the write queue; null
// aborts, otherwise the returned row is persisted.
transformPlaylistMeta = jest.fn((_id, transform) => {
if (!persistedRow) {
return of(null);
}
const next = transform(persistedRow) as Playlist | null;
if (next === null) {
return of(null);
}
if (persistError) {
return throwError(() => persistError);
}
writtenRow = next;
return of(next);
});
setCachedToken = jest.fn();
clearCachedToken = jest.fn();
refreshActiveWatchdogPlaylist = jest.fn();
TestBed.configureTestingModule({
providers: [
{
provide: StalkerPortalDiscoveryService,
useValue: { discover },
},
{
provide: PlaylistsService,
useValue: {
transformPlaylistMeta,
getPlaylistById: jest.fn(() => of(persistedRow)),
},
},
{
provide: StalkerSessionService,
useValue: {
setCachedToken,
clearCachedToken,
refreshActiveWatchdogPlaylist,
},
},
],
});
service = TestBed.inject(StalkerPortalRepairService);
});
describe('shouldAttemptRepair', () => {
it('triggers on the middleware plain-text auth bodies', () => {
expect(
service.shouldAttemptRepair(MISCLASSIFIED, 'Authorization failed.')
).toBe(true);
expect(
service.shouldAttemptRepair(
MISCLASSIFIED,
'Unauthorized request.'
)
).toBe(true);
});
it('triggers on HTTP 404 — the persisted endpoint does not exist', () => {
expect(
service.shouldAttemptRepair(MISCLASSIFIED, {
message: 'HTTP Error 404: Not Found',
status: 404,
})
).toBe(true);
});
it('triggers on HTTP 401/403 — discovery classifies those endpoints as auth-required', () => {
expect(
service.shouldAttemptRepair(MISCLASSIFIED, {
message: 'HTTP Error 401: Unauthorized',
status: 401,
})
).toBe(true);
expect(
service.shouldAttemptRepair(MISCLASSIFIED, {
message: 'HTTP Error 403: Forbidden',
status: 403,
})
).toBe(true);
// Endpoint-specific server errors are still not repair triggers.
expect(
service.shouldAttemptRepair(MISCLASSIFIED, {
message: 'HTTP Error 500: Internal Server Error',
status: 500,
})
).toBe(false);
});
it('triggers on terminal session auth errors', () => {
expect(
service.shouldAttemptRepair(
MISCLASSIFIED,
new Error('Authorization failed after retry')
)
).toBe(true);
expect(
service.shouldAttemptRepair(
MISCLASSIFIED,
new Error('Handshake failed: No token received')
)
).toBe(true);
});
it.each([
'Profile error: Access denied.',
'Profile error: Unauthorized request.',
'Profile error: Invalid token',
'Profile error: Auth failed',
])('triggers on the wrapped profile denial %j', (message) => {
// Authentication wraps structured denials; the trigger uses the
// same failure set as discovery and the session service, so
// these cannot bypass the repair.
expect(
service.shouldAttemptRepair(MISCLASSIFIED, new Error(message))
).toBe(true);
});
it('never triggers on timeouts or other network failures', () => {
expect(
service.shouldAttemptRepair(MISCLASSIFIED, {
type: 'ERROR',
message: 'timeout of 15000ms exceeded',
status: 500,
})
).toBe(false);
expect(
service.shouldAttemptRepair(MISCLASSIFIED, { js: [] })
).toBe(false);
});
it('never triggers for playlists without portal coordinates', () => {
expect(
service.shouldAttemptRepair(
{ _id: 'x', macAddress: 'mac' } as PlaylistMeta,
'Authorization failed.'
)
).toBe(false);
});
});
describe('repairPortal', () => {
it('persists a proven different mode and returns the patched playlist', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
token: 'TOKEN1',
});
const repaired = await service.repairPortal(MISCLASSIFIED);
expect(repaired).toMatchObject({
_id: 'portal-1',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
});
// The atomic transform patched the FRESH row (verified inside
// the write queue), so user state can never be clobbered.
expect(writtenRow).toMatchObject({
_id: 'portal-1',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
});
// The classification handshake already produced a token,
// tagged with the playlist as its identity source.
expect(setCachedToken).toHaveBeenCalledWith(
'portal-1',
'TOKEN1',
expect.objectContaining({ _id: 'portal-1' })
);
// A repaired ACTIVE playlist must re-sync the watchdog now: a
// simple→full flip has to start the keepalive mid-session.
expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'portal-1',
isFullStalkerPortal: true,
})
);
});
it('repairs a dead portal.php endpoint to the canonical one', async () => {
const wrongEndpoint = {
...MISCLASSIFIED,
portalUrl: 'http://ministra.example/portal.php',
} as PlaylistMeta;
persistedRow = wrongEndpoint as Playlist;
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
token: 'TOKEN2',
});
const repaired = await service.repairPortal(wrongEndpoint);
expect(repaired?.portalUrl).toBe(
'http://ministra.example/server/load.php'
);
expect(service.applyOverride(wrongEndpoint).portalUrl).toBe(
'http://ministra.example/server/load.php'
);
});
it('changes nothing when probing confirms the stored configuration', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: false,
});
const repaired = await service.repairPortal(MISCLASSIFIED);
expect(repaired).toBeNull();
expect(writtenRow).toBeNull();
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
});
it('changes nothing when the probe finds no working configuration', async () => {
discover.mockResolvedValue({ status: 'unreachable' });
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
});
it('changes nothing when the probe is rejected by the portal', async () => {
discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl: MISCLASSIFIED.portalUrl,
});
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
});
it('probes at most once per playlist per session', async () => {
discover.mockResolvedValue({ status: 'unreachable' });
await service.repairPortal(MISCLASSIFIED);
await service.repairPortal(MISCLASSIFIED);
expect(discover).toHaveBeenCalledTimes(1);
});
it('re-enters for an edited configuration after awaiting a pending probe', async () => {
// A's probe is in flight when a request from the EDITED config B
// fails: after A settles (and is discarded by the row guard), B
// must get its own probe instead of inheriting A's outcome.
const edited = {
...MISCLASSIFIED,
portalUrl: 'http://edited.example/portal.php',
} as PlaylistMeta;
persistedRow = edited as Playlist;
let resolveFirstDiscovery!: (value: unknown) => void;
discover.mockReturnValueOnce(
new Promise((resolve) => (resolveFirstDiscovery = resolve))
);
discover.mockResolvedValueOnce({
status: 'resolved',
portalUrl: 'http://edited.example/server/load.php',
isFullStalkerPortal: true,
});
const oldRepair = service.repairPortal(MISCLASSIFIED);
const editedRepair = service.repairPortal(edited);
resolveFirstDiscovery({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
expect(await oldRepair).toBeNull();
const repaired = await editedRepair;
expect(discover).toHaveBeenCalledTimes(2);
expect(repaired?.portalUrl).toBe(
'http://edited.example/server/load.php'
);
});
it('shares one in-flight probe between concurrent failing requests', async () => {
let resolveDiscovery!: (value: unknown) => void;
discover.mockReturnValue(
new Promise((resolve) => (resolveDiscovery = resolve))
);
const first = service.repairPortal(MISCLASSIFIED);
const second = service.repairPortal(MISCLASSIFIED);
resolveDiscovery({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
const [a, b] = await Promise.all([first, second]);
expect(discover).toHaveBeenCalledTimes(1);
expect(a?.isFullStalkerPortal).toBe(true);
expect(b?.isFullStalkerPortal).toBe(true);
});
it('hands out the completed override to later failing callers without re-probing', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
await service.repairPortal(MISCLASSIFIED);
// A caller still holding the stale playlist object fails and asks
// again: it gets the override without a second probe.
const again = await service.repairPortal(MISCLASSIFIED);
expect(discover).toHaveBeenCalledTimes(1);
expect(again?.isFullStalkerPortal).toBe(true);
// A caller already on the repaired configuration gets null — its
// failure has another cause, and retrying would loop.
const alreadyApplied = service.applyOverride(MISCLASSIFIED);
expect(await service.repairPortal(alreadyApplied)).toBeNull();
});
it('drops the override and re-arms probing when the user edits portal metadata', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
await service.repairPortal(MISCLASSIFIED);
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
isFullStalkerPortal: true,
});
// The user pointed the playlist somewhere else through the
// playlist dialog: the ID-keyed override must not keep rewriting
// requests to the old repaired endpoint.
const edited = {
...MISCLASSIFIED,
portalUrl: 'http://other.example/portal.php',
} as PlaylistMeta;
expect(service.applyOverride(edited)).toBe(edited);
// …and the once-per-session latch re-arms so the EDITED
// configuration may probe if it fails too.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://other.example/server/load.php',
isFullStalkerPortal: true,
});
persistedRow = edited as Playlist;
const repairedAgain = await service.repairPortal(edited);
expect(discover).toHaveBeenCalledTimes(2);
expect(repairedAgain?.portalUrl).toBe(
'http://other.example/server/load.php'
);
});
it('discards an in-flight repair when the row was edited during the probe', async () => {
// The probe can run for tens of seconds; a user who saves a new
// portal URL meanwhile must win over the repair of the old one.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
persistedRow = {
...MISCLASSIFIED,
portalUrl: 'http://edited.example/portal.php',
} as Playlist;
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
});
it('discards an in-flight repair when the MAC or identity changed during the probe', async () => {
// The probe authenticated AS an identity — a token and watchdog
// for the old MAC must not be installed onto the edited account.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
persistedRow = {
...MISCLASSIFIED,
macAddress: '00:1A:79:00:99:99',
} as Playlist;
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
expect(setCachedToken).not.toHaveBeenCalled();
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
});
it('never aliases distinct identities across field boundaries', async () => {
// Delimiter-style fingerprints would treat serial "a|b" +
// empty device as equal to serial "a" + device "b" and skip
// the identity invalidation entirely.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
const pipedIdentity = {
...MISCLASSIFIED,
stalkerSerialNumber: 'a|b',
} as PlaylistMeta;
persistedRow = pipedIdentity as Playlist;
await service.repairPortal(pipedIdentity);
clearCachedToken.mockClear();
const shiftedIdentity = {
...MISCLASSIFIED,
stalkerSerialNumber: 'a',
stalkerDeviceId1: 'b',
} as PlaylistMeta;
// A DIFFERENT identity must invalidate, not inherit.
expect(service.applyOverride(shiftedIdentity)).toBe(
shiftedIdentity
);
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
});
it('reinstalls the remembered repair when a restored configuration fails again', async () => {
// Repair A, edit to B (drops the active override), restore A:
// A's next failure must reinstall the remembered outcome
// WITHOUT a second discovery — not stay broken until restart.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
await service.repairPortal(MISCLASSIFIED);
expect(discover).toHaveBeenCalledTimes(1);
const editedIdentity = {
...MISCLASSIFIED,
macAddress: '00:1A:79:00:44:44',
} as PlaylistMeta;
// The edit drops the active override…
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
// …and the restored configuration reinstalls it on failure.
refreshActiveWatchdogPlaylist.mockClear();
const restored = await service.repairPortal(MISCLASSIFIED);
expect(discover).toHaveBeenCalledTimes(1);
expect(restored).toMatchObject({ isFullStalkerPortal: true });
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
isFullStalkerPortal: true,
});
// The reinstall re-syncs the watchdog exactly like a fresh
// repair — the intermediate edit may have stopped the keepalive.
expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith(
expect.objectContaining({ isFullStalkerPortal: true })
);
});
it('does not resurrect a remembered override while the row holds another config', async () => {
// Repair A→B, then the user edits the row to an unrelated C. A
// stale A request failing afterwards must NOT reinstall B (it
// would retry against B and repoint the watchdog away from C).
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://b.example/server/load.php',
isFullStalkerPortal: true,
});
await service.repairPortal(MISCLASSIFIED);
const otherConfig = {
...MISCLASSIFIED,
portalUrl: 'http://c.example/portal.php',
} as PlaylistMeta;
// The edit drops the active override…
expect(service.applyOverride(otherConfig)).toBe(otherConfig);
persistedRow = otherConfig as Playlist;
refreshActiveWatchdogPlaylist.mockClear();
// …and a stale A request does not bring it back.
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
});
it('drops the override and the cached token when only the identity was edited', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
await service.repairPortal(MISCLASSIFIED);
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
isFullStalkerPortal: true,
});
clearCachedToken.mockClear();
// Same URL and mode, different MAC: the repair token belongs to
// the previous identity and must be retired with the override.
const editedIdentity = {
...MISCLASSIFIED,
macAddress: '00:1A:79:00:88:88',
} as PlaylistMeta;
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
// The latch is re-armed for the edited identity.
discover.mockClear();
discover.mockResolvedValue({ status: 'unreachable' });
persistedRow = editedIdentity as Playlist;
await service.repairPortal(editedIdentity);
expect(discover).toHaveBeenCalledTimes(1);
});
it('re-probes a DISCARDED configuration once the row is restored to it', async () => {
// A's probe was discarded because the row moved to B mid-probe;
// after the user restores the row to A, A's next failure must
// probe again instead of staying dead for the session.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
persistedRow = {
...MISCLASSIFIED,
portalUrl: 'http://edited.example/portal.php',
} as Playlist;
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(discover).toHaveBeenCalledTimes(1);
// Row restored to A → the discarded marker yields to a new probe.
persistedRow = MISCLASSIFIED as Playlist;
const repaired = await service.repairPortal(MISCLASSIFIED);
expect(discover).toHaveBeenCalledTimes(2);
expect(repaired).toMatchObject({ isFullStalkerPortal: true });
});
it('re-arms the EDITED configuration after a mid-probe edit discarded a repair', async () => {
const edited = {
...MISCLASSIFIED,
portalUrl: 'http://edited.example/portal.php',
} as PlaylistMeta;
// Probe of the OLD config lands after the user edit → discarded.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
persistedRow = edited as Playlist;
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(discover).toHaveBeenCalledTimes(1);
// A stale snapshot of the already-probed config must NOT loop
// the probe…
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(discover).toHaveBeenCalledTimes(1);
// …but the EDITED configuration failing later must be allowed
// to repair without an application restart.
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://edited.example/server/load.php',
isFullStalkerPortal: true,
});
const repaired = await service.repairPortal(edited);
expect(discover).toHaveBeenCalledTimes(2);
expect(repaired?.portalUrl).toBe(
'http://edited.example/server/load.php'
);
});
it('discards an in-flight repair when the playlist was deleted during the probe', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
persistedRow = undefined;
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
expect(writtenRow).toBeNull();
});
it('keeps the session-only override when persisting fails', async () => {
discover.mockResolvedValue({
status: 'resolved',
portalUrl: MISCLASSIFIED.portalUrl,
isFullStalkerPortal: true,
});
// The transform verified the row, but the WRITE failed.
persistError = new Error('db locked');
const repaired = await service.repairPortal(MISCLASSIFIED);
expect(repaired?.isFullStalkerPortal).toBe(true);
expect(service.applyOverride(MISCLASSIFIED).isFullStalkerPortal).toBe(
true
);
});
it('clears a stale cached token when a portal turns out token-free', async () => {
const wronglyFull = {
...MISCLASSIFIED,
portalUrl: 'http://panel.example/server/load.php',
isFullStalkerPortal: true,
} as PlaylistMeta;
persistedRow = wronglyFull as Playlist;
discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'http://panel.example/portal.php',
isFullStalkerPortal: false,
});
const repaired = await service.repairPortal(wronglyFull);
expect(repaired?.isFullStalkerPortal).toBe(false);
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
});
});
});
@@ -0,0 +1,490 @@
import { Injectable, Injector, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import {
isFullStalkerPortalPlaylist,
type PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
import {
getStalkerRequestErrorStatus,
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
} from './stalker-portal-discovery.utils';
import {
getStalkerPortalIdentityFromPlaylist,
stalkerIdentityFingerprint,
} from './stalker-identity.utils';
import { StalkerSessionService } from './stalker-session.service';
import {
type StalkerPortalRepairApi,
toStalkerSessionPlaylist,
} from './stores/utils/stalker-request.utils';
/**
* What a probe of one source configuration concluded this session:
* an override to (re)install, 'no-change' (probed; the stored configuration
* is what probing proves, or nothing answered), or 'discarded' (the row
* moved on mid-probe, so the outcome never applied to any persisted state).
*/
type StalkerProbeRecord = StalkerPortalModeOverride | 'no-change' | 'discarded';
interface StalkerPortalModeOverride {
/** The failing configuration this repair replaced. */
sourcePortalUrl?: string;
sourceIsFullStalkerPortal: boolean;
/** MAC + Stalker identity the repair probe authenticated as. */
identityFingerprint: string;
/** The proven-working configuration. */
portalUrl: string;
isFullStalkerPortal: boolean;
}
/**
* Lazy repair for playlists whose persisted portal endpoint or mode is
* wrong. The flag used to be a URL-shape guess frozen at import, so a
* canonical `…/server/load.php` portal could sit misclassified as
* token-free forever — every request answered `Authorization failed.` and
* the only "fix" was deleting the playlist (losing favorites, recents and
* positions).
*
* Deliberately NOT an eager one-shot migration: a large share of users are
* on reseller `portal.php` panels that work without any auth, and nothing
* short of probing can distinguish those from misclassified canonical
* portals. Instead, repair is evidence-driven and conservative:
*
* - it runs only after a request ACTUALLY failed with a repair trigger
* (the middleware's plain-text auth bodies, or HTTP 404 — a portal that
* works is never probed, let alone rewritten);
* - it probes at most once per SOURCE CONFIGURATION (endpoint, mode, MAC,
* identity) per playlist per session — an edited configuration may probe
* when it fails, an already-probed one stays latched;
* - it persists only a configuration that discovery PROVED to answer, and
* only when that configuration differs from the failing one.
*
* A successful repair also installs an in-session override so already-held
* stale playlist objects (store state, route snapshots) start using the
* corrected endpoint immediately — the persisted row makes it permanent.
*/
@Injectable({ providedIn: 'root' })
export class StalkerPortalRepairService implements StalkerPortalRepairApi {
private readonly discovery = inject(StalkerPortalDiscoveryService);
// Resolved lazily: PlaylistsService pulls the whole persistence stack
// (IndexedDB, snackbar, translations) and is only needed at the moment a
// repair actually persists — never on the hot request path.
private readonly injector = inject(Injector);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly logger = createLogger('StalkerPortalRepair');
private readonly overrides = new Map<string, StalkerPortalModeOverride>();
/**
* The OUTCOME of every probe this session, per playlist, keyed by the
* source-configuration fingerprint: the override the probe produced, or
* null when it changed nothing. Keeping full history (not just the last
* entry) stops alternating edits (A→B→A) from re-running discovery via
* stale snapshots — and keeping the OUTCOME (not just an attempted
* flag) lets a configuration the user restores reinstall its remembered
* repair instead of staying broken until restart. A configuration never
* probed — including one whose mid-probe edit discarded a repair —
* probes when IT fails.
*/
private readonly probeHistory = new Map<
string,
Map<string, StalkerProbeRecord>
>();
private readonly pendingRepairs = new Map<
string,
Promise<PlaylistMeta | null>
>();
constructor() {
// The watchdog resolves its playlist from the persisted row; while a
// repair's persistence is pending (or failed) that row still carries
// the broken configuration, so pings must see the override too. The
// typeof guard keeps isolated TestBeds with partial session mocks
// working.
if (
typeof this.stalkerSession.registerWatchdogPlaylistDecorator ===
'function'
) {
this.stalkerSession.registerWatchdogPlaylistDecorator((playlist) =>
this.applyOverride(playlist)
);
}
}
/**
* Returns the playlist with a completed repair applied, or the playlist
* unchanged (same reference) when there is nothing to apply.
*
* The override is tied to the SOURCE configuration it repaired: it only
* rewrites objects still carrying that failing configuration (stale
* store snapshots). A playlist carrying anything else means the user
* edited the portal metadata through the playlist dialog — the override
* and the once-per-session probe latch are dropped so the edited
* configuration is used verbatim and may repair again if IT fails.
*/
applyOverride<T extends PlaylistMeta>(playlist: T): T {
const override = this.overrides.get(playlist._id);
if (!override) {
return playlist;
}
if (
stalkerIdentityFingerprint(playlist) !== override.identityFingerprint
) {
// The MAC or Stalker identity was edited after the repair. The
// override AND the token the repair authenticated for the
// PREVIOUS identity must go — otherwise requests and watchdog
// pings would pair the edited identity with a foreign session.
this.dropOverride(playlist._id);
return playlist;
}
if (
playlist.portalUrl === override.portalUrl &&
playlist.isFullStalkerPortal === override.isFullStalkerPortal
) {
// Already carrying the repaired values (e.g. a freshly read row).
return playlist;
}
if (
playlist.portalUrl === override.sourcePortalUrl &&
isFullStalkerPortalPlaylist(playlist) ===
override.sourceIsFullStalkerPortal
) {
return {
...playlist,
portalUrl: override.portalUrl,
isFullStalkerPortal: override.isFullStalkerPortal,
};
}
// The portal URL or mode was edited to something else entirely —
// same story: the edited configuration is used verbatim and the
// repair session state is retired.
this.dropOverride(playlist._id);
return playlist;
}
/**
* Retires the session artifacts a repair installed for a playlist: the
* ACTIVE override and the cached token (authenticated for the pre-edit
* identity/endpoint). The probe history deliberately survives: it both
* blocks re-probing of already-attempted configurations (A→B→A cannot
* loop discovery through stale snapshots) and lets a restored
* configuration reinstall its remembered repair.
*/
private dropOverride(playlistId: string): void {
this.overrides.delete(playlistId);
this.stalkerSession.clearCachedToken(playlistId);
}
/**
* Whether a failure justifies probing at all. Only the failure shapes a
* wrong endpoint/mode actually produces qualify: the middleware's
* plain-text/JSON auth failures (misclassified canonical portal
* answering a token-less request), HTTP 404 (persisted endpoint does
* not exist), HTTP 401/403 (endpoint behind an HTTP auth gate), and
* the session service's terminal auth/handshake errors. Timeouts and
* other network failures never trigger a probe —
* a portal that is temporarily down must not be reclassified.
*/
shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean {
if (!playlist._id || !playlist.portalUrl || !playlist.macAddress) {
return false;
}
if (isStalkerAuthFailureResponse(failure)) {
return true;
}
const status = getStalkerRequestErrorStatus(failure);
if (status === 404 || status === 401 || status === 403) {
// 404: the persisted endpoint does not exist on this server.
// 401/403: a token-free-classified playlist hit an HTTP auth
// gate — discovery classifies these endpoints as auth-required,
// so the repair must be allowed to reach it.
return true;
}
if (
failure !== null &&
typeof failure === 'object' &&
'message' in failure
) {
const message = String(
(failure as { message?: unknown }).message ?? ''
);
// The SAME failure set the session service uses for error
// messages — authentication wraps structured denials as
// `Error('Profile error: Access denied.')` or
// `Error('Profile error: Invalid token')`, and a narrower
// pattern here would let those bypass the repair entirely.
return (
isStalkerAuthFailureMessage(message) ||
/handshake failed/i.test(message)
);
}
return false;
}
/**
* Probes the stored portal and, when discovery proves a DIFFERENT
* working configuration, persists it and returns the patched playlist
* for a one-shot retry. Returns null when nothing may change: probe
* found nothing, probe confirmed the stored configuration (the failure
* has another cause, e.g. an expired subscription), or a repair for
* this playlist already ran this session.
*/
async repairPortal(playlist: PlaylistMeta): Promise<PlaylistMeta | null> {
const playlistId = playlist._id;
const pending = this.pendingRepairs.get(playlistId);
if (pending) {
// Wait the in-flight probe out, then RE-ENTER: the caller may
// carry a different (edited) configuration whose fingerprint
// was never attempted — it must get its own probe instead of
// inheriting whatever the old repair concluded.
await pending;
return this.repairPortal(playlist);
}
const fingerprint = this.repairSourceFingerprint(playlist);
const history = this.probeHistory.get(playlistId) ?? new Map();
const record = history.get(fingerprint) as
| StalkerProbeRecord
| undefined;
if (record === 'discarded') {
// The probe for this configuration was discarded because the
// row had moved on mid-probe. If the row has since been
// RESTORED to it, the outcome was never recorded — probe again.
// A stale snapshot (row still elsewhere) stays declined, gated
// by one cheap row read instead of a discovery run.
if (!(await this.rowCurrentlyMatches(playlist))) {
return this.reapplyIfChanged(playlist);
}
history.delete(fingerprint);
} else if (record !== undefined) {
if (
record !== 'no-change' &&
!this.overrides.has(playlistId) &&
// Reinstall ONLY when the persisted row actually carries
// this configuration again. A stale request for A while the
// row now holds an unrelated C must not resurrect A's
// override — that would retry against B and repoint the
// watchdog away from C.
(await this.rowCurrentlyMatches(playlist))
) {
// The user restored a configuration whose override was
// dropped by an intermediate edit: reinstall the remembered
// outcome — probing again is unnecessary, and doing nothing
// would leave the restored configuration broken until
// restart.
this.overrides.set(playlistId, record);
// Same synchronization as a fresh repair: if the
// intermediate configuration stopped the active watchdog,
// the restored full-portal session needs its keepalive back.
this.stalkerSession.refreshActiveWatchdogPlaylist(
toStalkerSessionPlaylist(this.applyOverride(playlist))
);
}
return this.reapplyIfChanged(playlist);
}
// Reserved BEFORE the probe; the run overwrites it with the
// produced override or the 'discarded' marker.
history.set(fingerprint, 'no-change');
this.probeHistory.set(playlistId, history);
const run = this.runRepair(playlist);
this.pendingRepairs.set(playlistId, run);
try {
return await run;
} finally {
this.pendingRepairs.delete(playlistId);
}
}
/**
* Everything a probe's outcome depends on: endpoint, mode, MAC and the
* full Stalker identity — the same field set `rowStillMatchesSource`
* verifies before committing.
*/
private repairSourceFingerprint(playlist: PlaylistMeta): string {
// JSON-encoded for the same reason as the identity fingerprint:
// unrestricted values must not alias across field boundaries.
return JSON.stringify([
playlist.portalUrl ?? '',
isFullStalkerPortalPlaylist(playlist),
stalkerIdentityFingerprint(playlist),
]);
}
private reapplyIfChanged(playlist: PlaylistMeta): PlaylistMeta | null {
const applied = this.applyOverride(playlist);
return applied === playlist ? null : applied;
}
private async runRepair(
playlist: PlaylistMeta
): Promise<PlaylistMeta | null> {
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
getStalkerPortalIdentityFromPlaylist(playlist)
);
if (outcome.status !== 'resolved') {
this.logger.info(
`Portal probe found no working configuration (${outcome.status}); leaving playlist untouched`
);
return null;
}
const storedMode = isFullStalkerPortalPlaylist(playlist);
if (
outcome.portalUrl === playlist.portalUrl &&
outcome.isFullStalkerPortal === storedMode
) {
// The stored configuration is exactly what probing proves — the
// failure has a different cause and rewriting would fix nothing.
return null;
}
// TOCTOU guard: the probe can run for tens of seconds, and the user
// may have edited the portal metadata (or deleted the playlist)
// meanwhile. The verification and the patch run ATOMICALLY inside
// the per-playlist write queue — a plain read-check-then-update
// pair would still race an edit that is queued but not committed.
// The transform patches the FRESH row, so nothing stale can clobber
// user state; returning null aborts without writing.
let verifiedAgainstRow = false;
try {
await firstValueFrom(
this.injector
.get(PlaylistsService)
.transformPlaylistMeta(playlist._id, (row) => {
if (!this.rowMatchesSource(row, playlist, storedMode)) {
return null;
}
verifiedAgainstRow = true;
return {
...row,
portalUrl: outcome.portalUrl,
isFullStalkerPortal: outcome.isFullStalkerPortal,
};
})
);
} catch (error) {
// A failed WRITE after successful verification keeps the
// session-only override below; a failed READ means the premise
// could not be verified and the repair is discarded.
this.logger.warn(
'Persisting repaired portal mode failed',
error
);
}
if (!verifiedAgainstRow) {
this.logger.info(
'Portal configuration changed while probing; discarding repair'
);
// Marked explicitly: a later failure of this configuration may
// probe again once the row is RESTORED to it — unlike a probe
// whose outcome genuinely applied ('no-change'/override).
this.probeHistory
.get(playlist._id)
?.set(this.repairSourceFingerprint(playlist), 'discarded');
return null;
}
const override: StalkerPortalModeOverride = {
sourcePortalUrl: playlist.portalUrl,
sourceIsFullStalkerPortal: storedMode,
identityFingerprint: stalkerIdentityFingerprint(playlist),
portalUrl: outcome.portalUrl,
isFullStalkerPortal: outcome.isFullStalkerPortal,
};
this.overrides.set(playlist._id, override);
this.probeHistory
.get(playlist._id)
?.set(this.repairSourceFingerprint(playlist), override);
if (outcome.isFullStalkerPortal && outcome.token) {
// The classification handshake already authenticated; reuse its
// token so the retry does not immediately handshake again. The
// playlist itself is the identity source — a repair never
// changes WHO the session belongs to, only WHERE it talks.
this.stalkerSession.setCachedToken(
playlist._id,
outcome.token,
playlist
);
} else if (!outcome.isFullStalkerPortal) {
this.stalkerSession.clearCachedToken(playlist._id);
}
// If this playlist currently owns the watchdog, re-sync it with the
// repaired configuration: a simple→full repair must START the
// keepalive and an endpoint change must repoint it — the session
// service otherwise keeps the activation-time snapshot forever.
this.stalkerSession.refreshActiveWatchdogPlaylist(
toStalkerSessionPlaylist(this.applyOverride(playlist))
);
this.logger.info(
`Repaired portal mode: isFullStalkerPortal=${outcome.isFullStalkerPortal}`
);
return this.applyOverride(playlist);
}
/**
* Cheap gate for retrying a DISCARDED configuration: reads the current
* row and reports whether it now carries the caller's configuration.
* Only avoids pointless discovery runs — the authoritative check stays
* the atomic transform.
*/
private async rowCurrentlyMatches(
playlist: PlaylistMeta
): Promise<boolean> {
try {
const row = await firstValueFrom(
this.injector
.get(PlaylistsService)
.getPlaylistById(playlist._id)
);
return (
!!row &&
this.repairSourceFingerprint(row) ===
this.repairSourceFingerprint(playlist)
);
} catch {
return false;
}
}
/**
* Whether the persisted row still carries the configuration the repair
* was computed for — endpoint, mode, and the identity the probe
* authenticated as. Runs synchronously INSIDE the atomic transform.
*/
private rowMatchesSource(
row: PlaylistMeta,
playlist: PlaylistMeta,
sourceMode: boolean
): boolean {
return (
row.portalUrl === playlist.portalUrl &&
isFullStalkerPortalPlaylist(row) === sourceMode &&
stalkerIdentityFingerprint(row) ===
stalkerIdentityFingerprint(playlist)
);
}
}
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { of } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import {
STALKER_SERIAL_NUMBER,
@@ -23,6 +24,327 @@ type GetProfileWithIdentity = (
handshakeRandom: string
) => Promise<StalkerProfileResponse>;
describe('StalkerSessionService watchdog row resolution', () => {
const activationSnapshot = {
_id: 'portal-1',
title: 'Portal',
portalUrl: 'https://portal.example.com/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: true,
lastUsage: '',
} as unknown as Playlist;
let sendIpcEvent: jest.Mock;
let getPlaylistById: jest.Mock;
let service: StalkerSessionService;
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
sendIpcEvent = jest
.fn()
.mockResolvedValue({ js: { token: 'TOK', random: 'r' } });
getPlaylistById = jest.fn();
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: { sendIpcEvent } },
{
provide: PlaylistsService,
useValue: { getPlaylistById },
},
],
});
service = TestBed.inject(StalkerSessionService);
});
it('authenticates watchdog pings as the freshly persisted row, not the activation snapshot', async () => {
// The user edited the MAC after the watchdog started: the very next
// ping must use the stored row — pairing the old identity would
// keep an old session alive and repopulate the token cache with it.
const editedRow = {
...activationSnapshot,
macAddress: '00:1A:79:00:77:77',
};
getPlaylistById.mockReturnValue(of(editedRow));
service.setActiveWatchdogPlaylist(activationSnapshot);
// The init ping runs on a floating promise chain.
for (let i = 0; i < 20; i += 1) {
await Promise.resolve();
}
expect(getPlaylistById).toHaveBeenCalledWith('portal-1');
expect(sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
macAddress: '00:1A:79:00:77:77',
})
);
service.setActiveWatchdogPlaylist(null);
});
it('overlays the registered repair decorator on the resolved row', async () => {
// Simple→full repair whose persistence has not landed yet: the row
// still says simple, and without the overlay the ping would stop
// the freshly started keepalive.
const simpleRow = {
...activationSnapshot,
isFullStalkerPortal: false,
};
getPlaylistById.mockReturnValue(of(simpleRow));
service.registerWatchdogPlaylistDecorator((playlist) => ({
...playlist,
isFullStalkerPortal: true,
}));
service.setActiveWatchdogPlaylist(activationSnapshot);
for (let i = 0; i < 20; i += 1) {
await Promise.resolve();
}
// The keepalive survived (no stopWatchdog) and authenticated.
expect(sendIpcEvent).toHaveBeenCalled();
service.setActiveWatchdogPlaylist(null);
});
});
describe('StalkerSessionService identity-tagged token cache', () => {
const playlistA = {
_id: 'portal-1',
title: 'Portal',
portalUrl: 'https://portal.example.com/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: true,
lastUsage: '',
} as unknown as Playlist;
let sendIpcEvent: jest.Mock;
let service: StalkerSessionService;
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
sendIpcEvent = jest
.fn()
.mockResolvedValue({ js: { token: 'FRESH', random: 'r' } });
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: { sendIpcEvent } },
],
});
service = TestBed.inject(StalkerSessionService);
});
it('reuses a cached token only for the identity it was negotiated for', async () => {
service.setCachedToken('portal-1', 'OLD-IDENTITY-TOKEN', playlistA);
const sameIdentity = await service.ensureToken(playlistA);
expect(sameIdentity.token).toBe('OLD-IDENTITY-TOKEN');
expect(sendIpcEvent).not.toHaveBeenCalled();
// The user edited the MAC: the cached session belongs to the old
// identity and must be replaced by a fresh authentication.
const editedIdentity = {
...playlistA,
macAddress: '00:1A:79:00:66:66',
} as Playlist;
const reAuthenticated = await service.ensureToken(editedIdentity);
expect(reAuthenticated.token).toBe('FRESH');
expect(sendIpcEvent).toHaveBeenCalled();
});
it('does not hand an in-flight authentication result to an edited identity', async () => {
// Deferred transport: the first auth (old identity) is still in
// flight when the edited identity asks for a token.
const pendingResolvers: Array<(value: unknown) => void> = [];
sendIpcEvent.mockImplementation(
() =>
new Promise((resolve) => {
pendingResolvers.push(resolve);
})
);
const oldAuth = service.ensureToken(playlistA);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
const editedIdentity = {
...playlistA,
macAddress: '00:1A:79:00:55:55',
} as Playlist;
const editedAuth = service.ensureToken(editedIdentity);
// Settle the OLD identity's handshake + profile.
pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } });
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
pendingResolvers[1]?.({ js: {} });
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
// The edited identity re-enters and negotiates its OWN session.
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
pendingResolvers[2]?.({ js: { token: 'TOKEN-NEW', random: 'r' } });
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
pendingResolvers[3]?.({ js: {} });
await expect(editedAuth).resolves.toMatchObject({
token: 'TOKEN-NEW',
});
});
it('treats IPC-wrapped HTTP 401/403 as an authorization failure', async () => {
// The custom `status` property does not survive ipcRenderer, so an
// expired-token 403 arrives as message text only.
service.setCachedToken('portal-1', 'EXPIRED', playlistA);
sendIpcEvent.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'STALKER_REQUEST': HTTP Error 403: Forbidden"
)
);
sendIpcEvent.mockResolvedValue({ js: { token: 'FRESH', random: 'r' } });
await service
.makeAuthenticatedRequest(playlistA, { action: 'get_genres' })
.catch(() => undefined);
// The dead token was retired rather than kept for the next caller.
expect(service.getCachedToken('portal-1')).not.toBe('EXPIRED');
});
it.each(['Access denied.', 'Unauthorized request.'])(
'retires the token for the %j plain-text failure too',
async (body) => {
// The session predicate shares the discovery/repair failure
// set: a phrase one layer treats as an auth failure must not be
// ignored here, or the expired token survives the session.
service.setCachedToken('portal-1', 'EXPIRED', playlistA);
sendIpcEvent.mockResolvedValue(body);
await service
.makeAuthenticatedRequest(
playlistA,
{ action: 'get_genres' },
false
)
.catch(() => undefined);
expect(service.getCachedToken('portal-1')).toBeNull();
}
);
it('retires a failed token even on the no-retry path (watchdog pings)', async () => {
service.setCachedToken('portal-1', 'DEAD', playlistA);
sendIpcEvent.mockResolvedValue('Authorization failed.');
await expect(
service.makeAuthenticatedRequest(
playlistA,
{ action: 'get_events' },
false
)
).rejects.toThrow('Authorization failed after retry');
// Leaving the dead token cached would hand it to the next caller.
expect(service.getCachedToken('portal-1')).toBeNull();
});
});
describe('StalkerSessionService.refreshActiveWatchdogPlaylist', () => {
const basePlaylist = {
_id: 'portal-1',
title: 'Portal',
portalUrl: 'https://portal.example.com/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: false,
lastUsage: '',
} as unknown as Playlist;
let service: StalkerSessionService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
},
},
],
});
service = TestBed.inject(StalkerSessionService);
});
it('re-applies the repaired configuration to the ACTIVE watchdog playlist', () => {
service.setActiveWatchdogPlaylist(basePlaylist);
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
const repaired = {
...basePlaylist,
isFullStalkerPortal: true,
} as Playlist;
service.refreshActiveWatchdogPlaylist(repaired);
// Delegation is the contract: setActiveWatchdogPlaylist owns the
// start/stop/repoint logic, refresh only feeds it the fresh row.
expect(apply).toHaveBeenCalledWith(repaired);
service.setActiveWatchdogPlaylist(null);
});
it('ignores playlists that do not own the watchdog', () => {
service.setActiveWatchdogPlaylist(basePlaylist);
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
service.refreshActiveWatchdogPlaylist({
...basePlaylist,
_id: 'other-portal',
isFullStalkerPortal: true,
} as Playlist);
expect(apply).not.toHaveBeenCalled();
service.setActiveWatchdogPlaylist(null);
});
it('is a no-op when no watchdog playlist is active at all', () => {
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
service.refreshActiveWatchdogPlaylist({
...basePlaylist,
isFullStalkerPortal: true,
} as Playlist);
expect(apply).not.toHaveBeenCalled();
});
});
describe('StalkerSessionService identity payloads', () => {
const portalUrl =
'https://portal.example.com/stalker_portal/server/load.php';
@@ -261,7 +583,7 @@ describe('StalkerSessionService identity payloads', () => {
isFullStalkerPortal: true,
} as Playlist;
service.setCachedToken(playlist._id, 'stale-token');
service.setCachedToken(playlist._id, 'stale-token', playlist);
let release: (value: { token: string }) => void = () => undefined;
jest.spyOn(service, 'authenticate').mockImplementation(
@@ -296,7 +618,7 @@ describe('StalkerSessionService identity payloads', () => {
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'stale-token' })
.mockResolvedValueOnce({ token: 'fresh-token' });
service.setCachedToken(playlist._id, 'fresh-token');
service.setCachedToken(playlist._id, 'fresh-token', playlist);
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
@@ -327,7 +649,7 @@ describe('StalkerSessionService identity payloads', () => {
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'dead-token' })
.mockResolvedValueOnce({ token: 'new-token' });
service.setCachedToken(playlist._id, 'dead-token');
service.setCachedToken(playlist._id, 'dead-token', playlist);
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
@@ -1,11 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { DataService } from '@iptvnator/services';
import { Injectable, Injector, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
isFullStalkerPortalPlaylist,
isFullStalkerPortalUrl,
Playlist,
PlaylistMeta,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
} from './stalker-portal-discovery.utils';
import {
getStalkerPortalIdentityFromPlaylist,
LEGACY_DEFAULT_STALKER_SERIAL,
normalizeStalkerPortalIdentity,
stalkerIdentityFingerprint,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
@@ -88,16 +100,33 @@ interface StalkerAuthConfirmationResponse {
})
export class StalkerSessionService {
private dataService = inject(DataService);
// Lazy: PlaylistsService drags the persistence stack and is only needed
// when a watchdog ping re-resolves its playlist from the stored row.
private readonly injector = inject(Injector);
private readonly logger = createLogger('StalkerSession');
// In-memory token cache for current session (keyed by playlist ID)
private tokenCache = new Map<string, string>();
// In-memory token cache for the current session, keyed by playlist ID
// and tagged with the identity fingerprint the session was negotiated
// for — an edited MAC/identity must never inherit the previous token.
private tokenCache = new Map<
string,
{ token: string; identityFingerprint: string }
>();
private watchdogPlaylistDecorator:
| ((playlist: Playlist) => Playlist)
| null = null;
// Pending authentication promises to prevent race conditions
// When multiple requests need a token simultaneously, they all wait for the same auth
// Pending authentication promises to prevent race conditions.
// When multiple requests need a token simultaneously, they all wait for
// the same auth — but ONLY when they act as the same identity: a result
// negotiated for a pre-edit identity must not be adopted by requests
// carrying the edited one.
private pendingAuth = new Map<
string,
Promise<{ token: string; serialNumber?: string }>
{
promise: Promise<{ token: string; serialNumber?: string }>;
identityFingerprint: string;
}
>();
private watchdogIntervals = new Map<
string,
@@ -108,27 +137,49 @@ export class StalkerSessionService {
private activeWatchdogPlaylistId: string | null = null;
/**
* Checks if a URL is a full stalker portal URL (requires handshake)
* Full stalker portal URLs contain /stalker_portal/ in the path
* Checks if a URL looks like a full stalker portal URL (requires
* handshake). Delegates to the shared predicate in
* `@iptvnator/shared/interfaces` — the flag persisted by endpoint
* discovery is authoritative; this URL rule is only the legacy fallback.
*/
isFullStalkerPortal(url: string): boolean {
return (
url.includes('/stalker_portal/') || url.includes('/server/load.php')
);
return isFullStalkerPortalUrl(url);
}
/**
* Gets the cached token for a playlist, or null if not cached
* Gets the cached token for a playlist, or null if not cached.
* Identity validation happens in `ensureToken`; this raw accessor stays
* for playback fast paths that cannot supply an identity (PR 6 scope).
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId) || null;
return this.tokenCache.get(playlistId)?.token || null;
}
/**
* Sets a token in the cache
* Caches a token together with the identity fingerprint of the playlist
* the session was negotiated for.
*/
setCachedToken(playlistId: string, token: string): void {
this.tokenCache.set(playlistId, token);
setCachedToken(
playlistId: string,
token: string,
identitySource: PlaylistMeta
): void {
this.tokenCache.set(playlistId, {
token,
identityFingerprint: stalkerIdentityFingerprint(identitySource),
});
}
/**
* Lets the repair layer overlay its in-session override on the row a
* watchdog ping resolves: the persisted row can still carry a
* pre-repair configuration while persistence is pending (or failed),
* and pinging that configuration would stop or misdirect the keepalive.
*/
registerWatchdogPlaylistDecorator(
decorator: (playlist: Playlist) => Playlist
): void {
this.watchdogPlaylistDecorator = decorator;
}
/**
@@ -156,7 +207,7 @@ export class StalkerSessionService {
if (
!playlist ||
!playlist.isFullStalkerPortal ||
!isFullStalkerPortalPlaylist(playlist) ||
!playlist.portalUrl ||
!playlist.macAddress
) {
@@ -169,6 +220,22 @@ export class StalkerSessionService {
this.startWatchdog(playlist);
}
/**
* Re-evaluates the watchdog for a playlist whose portal configuration
* was just repaired. Only reacts when the playlist IS the active
* watchdog target: a simple→full repair starts the required keepalive,
* full→simple stops it, and an endpoint change repoints the pings —
* without waiting for the next route activation (the activation-time
* snapshot in `watchdogPlaylists` would otherwise stay stale).
*/
refreshActiveWatchdogPlaylist(playlist: Playlist): void {
if (this.activeWatchdogPlaylistId !== playlist._id) {
return;
}
this.setActiveWatchdogPlaylist(playlist);
}
private startWatchdog(playlist: Playlist): void {
const playlistId = playlist._id;
this.watchdogPlaylists.set(playlistId, playlist);
@@ -196,6 +263,47 @@ export class StalkerSessionService {
this.watchdogInFlight.delete(playlistId);
}
/**
* The playlist a watchdog ping authenticates as. The persisted row is
* the source of truth: portal metadata (endpoint, mode, MAC, identity)
* edited or repaired mid-session must reach the keepalive within one
* ping cycle — the activation-time snapshot is only the fallback when
* the row cannot be read.
*/
private async resolveWatchdogPlaylist(
playlistId: string
): Promise<Playlist | undefined> {
try {
const row = await firstValueFrom(
this.injector
.get(PlaylistsService)
.getPlaylistById(playlistId)
);
if (row) {
const playlist = row as Playlist;
// Keep the fallback snapshot fresh for the next cycle.
this.watchdogPlaylists.set(playlistId, playlist);
return this.decorateWatchdogPlaylist(playlist);
}
} catch {
// Store unavailable (e.g. isolated tests): keep the snapshot.
}
const snapshot = this.watchdogPlaylists.get(playlistId);
return snapshot ? this.decorateWatchdogPlaylist(snapshot) : snapshot;
}
/**
* Overlays the repair layer's in-session override (when registered) so
* a ping never authenticates against a configuration a completed repair
* has already proven broken — even before persistence lands.
*/
private decorateWatchdogPlaylist(playlist: Playlist): Playlist {
return this.watchdogPlaylistDecorator
? this.watchdogPlaylistDecorator(playlist)
: playlist;
}
private async sendWatchdogPing(
playlistId: string,
init: '0' | '1'
@@ -204,19 +312,21 @@ export class StalkerSessionService {
return;
}
const playlist = this.watchdogPlaylists.get(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!playlist.isFullStalkerPortal
) {
this.stopWatchdog(playlistId);
return;
}
// Claimed BEFORE the row read: it awaits, and two overlapping pings
// passing the check together would double-fire the keepalive.
this.watchdogInFlight.add(playlistId);
try {
const playlist = await this.resolveWatchdogPlaylist(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!isFullStalkerPortalPlaylist(playlist)
) {
this.stopWatchdog(playlistId);
return;
}
await this.makeAuthenticatedRequest(
playlist,
{
@@ -417,6 +527,8 @@ export class StalkerSessionService {
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
/** Raw envelope so callers can apply their own failure checks. */
profileResponse?: StalkerProfileResponse;
}> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
@@ -451,6 +563,7 @@ export class StalkerSessionService {
return {
token,
accountInfo: profileResponse?.js?.account_info,
profileResponse,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
@@ -468,24 +581,52 @@ export class StalkerSessionService {
playlist: Playlist
): Promise<{ token: string | null; serialNumber?: string }> {
// If not a full stalker portal, no token needed
if (!playlist.isFullStalkerPortal) {
if (!isFullStalkerPortalPlaylist(playlist)) {
return { token: null };
}
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
return { token: cachedToken, serialNumber: identity.serialNumber };
// Check in-memory cache first (valid for current session only).
const cached = this.tokenCache.get(playlist._id);
if (cached) {
if (
cached.identityFingerprint ===
stalkerIdentityFingerprint(playlist)
) {
return {
token: cached.token,
serialNumber: identity.serialNumber,
};
}
// The cached session was negotiated for a DIFFERENT identity
// (the playlist was edited): retire it and authenticate as the
// current one instead of pairing new identity with old session.
this.clearCachedToken(playlist._id);
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingPromise = this.pendingAuth.get(playlist._id);
if (pendingPromise) {
this.logger.debug('Waiting for pending authentication...');
return pendingPromise;
const pendingEntry = this.pendingAuth.get(playlist._id);
if (pendingEntry) {
if (
pendingEntry.identityFingerprint ===
stalkerIdentityFingerprint(playlist)
) {
this.logger.debug('Waiting for pending authentication...');
return pendingEntry.promise;
}
// An authentication for a DIFFERENT (pre-edit) identity is in
// flight. Its result must not be adopted, but starting a
// competing handshake would strand it with a dead token on
// strict portals — wait for it to settle, then re-enter and
// authenticate as the current identity.
this.logger.debug(
'Waiting out an authentication for a different identity...'
);
await pendingEntry.promise.catch(() => undefined);
return this.ensureToken(playlist);
}
// No cached token - need to do full authentication (handshake + get_profile)
@@ -506,7 +647,7 @@ export class StalkerSessionService {
macAddress,
identity
);
this.setCachedToken(playlist._id, token);
this.setCachedToken(playlist._id, token, playlist);
return { token, serialNumber: identity.serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
@@ -515,7 +656,10 @@ export class StalkerSessionService {
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, authPromise);
this.pendingAuth.set(playlist._id, {
promise: authPromise,
identityFingerprint: stalkerIdentityFingerprint(playlist),
});
return authPromise;
}
@@ -553,7 +697,7 @@ export class StalkerSessionService {
this.logger.debug('Waiting for pending authentication...');
// A failed pending auth must not abort the refresh; this call
// performs its own handshake either way.
await inFlight.catch(() => undefined);
await inFlight.promise.catch(() => undefined);
}
// Publish the slot before the first await so no other waiter can
@@ -574,7 +718,11 @@ export class StalkerSessionService {
// Waiters attach their own handlers; this one only keeps a
// rejected slot from surfacing as an unhandled rejection.
void slot.catch(() => undefined);
this.pendingAuth.set(playlist._id, slot);
const slotEntry = {
promise: slot,
identityFingerprint: stalkerIdentityFingerprint(playlist),
};
this.pendingAuth.set(playlist._id, slotEntry);
// ensureToken() reads tokenCache before pendingAuth, so leaving the
// old token there would hand a token this handshake is about to
@@ -588,7 +736,7 @@ export class StalkerSessionService {
macAddress,
identity
);
this.setCachedToken(playlist._id, result.token);
this.setCachedToken(playlist._id, result.token, playlist);
settleSlot({
token: result.token,
serialNumber: identity.serialNumber,
@@ -600,7 +748,7 @@ export class StalkerSessionService {
} finally {
// Only retire our own entry: a caller that started a later
// authentication owns the map slot from then on.
if (this.pendingAuth.get(playlist._id) === slot) {
if (this.pendingAuth.get(playlist._id) === slotEntry) {
this.pendingAuth.delete(playlist._id);
}
}
@@ -630,6 +778,29 @@ export class StalkerSessionService {
const response = responseOrError as Record<string, unknown>;
// The complete set of portal auth failures — the plain-text bodies
// (`Authorization failed.`, `Access denied.`, `Unauthorized
// request.`) and their JSON-envelope forms. Shared with endpoint
// discovery and the lazy repair so a phrase one layer classifies as
// an auth failure cannot be ignored by another: the session would
// otherwise keep an expired token and every later request fails.
if (
isStalkerAuthFailureResponse(responseOrError) ||
isStalkerAuthFailureMessage(response?.['message'])
) {
return true;
}
// HTTP auth codes surviving the IPC boundary only as message text
// (`HTTP Error 401: …`): the custom `status` property is stripped by
// ipcRenderer, so the numeric code must be read from the message.
if (
typeof response?.['message'] === 'string' &&
/HTTP Error 40[13]\b/.test(response['message'])
) {
return true;
}
// Convert response to string for pattern matching
const responseStr = JSON.stringify(responseOrError).toLowerCase();
@@ -682,9 +853,11 @@ export class StalkerSessionService {
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
// Retire the failed token so the retry re-authenticates
this.retireFailedToken(playlist._id, token);
// Retire the failed token even when not retrying (e.g.
// watchdog pings): leaving it cached would hand a dead
// session to the next caller.
this.retireFailedToken(playlist._id, token);
if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) {
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
@@ -699,18 +872,21 @@ export class StalkerSessionService {
return response;
} catch (error) {
// Check if error indicates auth failure
if (
this.isAuthorizationError(error) &&
retryOnAuthFailure &&
playlist.isFullStalkerPortal
) {
// Retire the failed token and retry with new handshake
if (this.isAuthorizationError(error)) {
// Same rule as above: a failed session is retired even on
// the no-retry path.
this.retireFailedToken(playlist._id, token);
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
if (
retryOnAuthFailure &&
isFullStalkerPortalPlaylist(playlist)
) {
// Retry with a fresh handshake
return this.makeAuthenticatedRequest<T>(
playlist,
params,
false
);
}
}
throw error;
}
@@ -148,7 +148,6 @@ describe('StalkerStore API compatibility smoke', () => {
'addToRecentlyViewed',
'removeFromRecentlyViewed',
'fetchChannelEpg',
'makeStalkerRequest',
];
for (const methodName of expectedMethods) {
@@ -18,6 +18,7 @@ import {
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import {
ResourceState,
@@ -194,6 +195,7 @@ export function withStalkerContent() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService)
) => {
@@ -202,6 +204,7 @@ export function withStalkerContent() {
const requestDeps = {
dataService,
stalkerSession,
portalRepair,
};
return {
@@ -16,6 +16,7 @@ import {
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
import { executeStalkerRequest } from '../utils';
@@ -101,6 +102,7 @@ export function withStalkerEpg() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
runtime = inject(RuntimeCapabilitiesService),
epgBridge = inject(EpgRuntimeBridgeService)
) => {
@@ -109,6 +111,7 @@ export function withStalkerEpg() {
const requestDeps = {
dataService,
stalkerSession,
portalRepair,
};
const supportsEpg = (): boolean => runtime.supportsEpg;
@@ -16,6 +16,7 @@ import {
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import {
normalizeStalkerEntityId,
@@ -54,6 +55,7 @@ export function withStalkerPlayer() {
playlistService = inject(PlaylistsService),
playerService = inject(PORTAL_PLAYER),
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
@@ -63,6 +65,7 @@ export function withStalkerPlayer() {
const requestDeps = {
dataService,
stalkerSession,
portalRepair,
};
const createRequestPlaylist = (
@@ -1,7 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPortal } from './with-stalker-portal.feature';
@@ -38,6 +39,7 @@ describe('withStalkerPortal', () => {
ensureToken: jest.Mock;
setActiveWatchdogPlaylist: jest.Mock;
};
let applyOverride: jest.Mock;
beforeEach(() => {
dbCreatePlaylist = jest.fn().mockResolvedValue(undefined);
@@ -57,6 +59,7 @@ describe('withStalkerPortal', () => {
ensureToken: jest.fn(),
setActiveWatchdogPlaylist: jest.fn(),
};
applyOverride = jest.fn((playlist) => playlist);
TestBed.configureTestingModule({
providers: [
@@ -75,6 +78,10 @@ describe('withStalkerPortal', () => {
provide: StalkerSessionService,
useValue: stalkerSession,
},
{
provide: StalkerPortalRepairService,
useValue: { applyOverride },
},
],
});
@@ -100,6 +107,29 @@ describe('withStalkerPortal', () => {
);
});
it('hands the repaired configuration to the watchdog and store on activation', async () => {
// Route re-activation passes the stale NgRx meta; the repair
// override must win here, or reopening the portal would stop or
// repoint the repaired keepalive back to the broken configuration.
const repaired = {
...PLAYLIST,
portalUrl: 'http://demo.example/server/load.php',
isFullStalkerPortal: true,
};
applyOverride.mockReturnValue(repaired);
await store.setCurrentPlaylist(PLAYLIST);
expect(applyOverride).toHaveBeenCalledWith(PLAYLIST);
expect(stalkerSession.setActiveWatchdogPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'http://demo.example/server/load.php',
isFullStalkerPortal: true,
})
);
expect(store.currentPlaylist()).toEqual(repaired);
});
it('does not touch SQLite when the Electron bridge is partial', async () => {
runtime.supportsStalkerPlaylistSqliteSync = false;
@@ -109,21 +139,4 @@ describe('withStalkerPortal', () => {
expect(dbCreatePlaylist).not.toHaveBeenCalled();
});
it('sends Stalker requests through DataService without requiring the SQLite bridge', async () => {
const dataService = TestBed.inject(DataService) as unknown as {
sendIpcEvent: jest.Mock;
};
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
await store.makeStalkerRequest(PLAYLIST, { action: 'get_profile' });
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
STALKER_REQUEST,
expect.objectContaining({
macAddress: '00:1A:79:00:00:01',
params: { action: 'get_profile' },
url: 'http://demo.example/stalker_portal/server/load.php',
})
);
});
});
@@ -3,12 +3,12 @@ import {
patchState,
signalStoreFeature,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { toStalkerSessionPlaylist } from '../utils';
@@ -40,57 +40,32 @@ export function withStalkerPortal() {
const logger = createLogger('withStalkerPortal');
return signalStoreFeature(
withState<StalkerPortalState>(initialPortalState),
withProps(
(
_store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, string | number>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if (playlist.isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
toStalkerSessionPlaylist(playlist)
);
token = result.token ?? undefined;
serialNumber = result.serialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
}
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
},
})
),
// NOTE: the old `makeStalkerRequest` prop was removed — it was a
// production-dead fourth copy of the portal-mode branch. All request
// paths go through `executeStalkerRequest` (stores/utils), which
// applies the shared predicate and the lazy portal repair.
withMethods(
(
store,
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
runtime = inject(RuntimeCapabilitiesService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
// A lazy repair may have corrected this playlist's
// endpoint/mode while the NgRx meta stayed stale; route
// re-activation must not hand the stale snapshot back to
// the watchdog (it would stop or repoint the repaired
// keepalive) or into the store state.
const effectivePlaylist = playlist
? portalRepair.applyOverride(playlist)
: playlist;
stalkerSession.setActiveWatchdogPlaylist(
playlist
? toStalkerSessionPlaylist(playlist)
effectivePlaylist
? toStalkerSessionPlaylist(effectivePlaylist)
: undefined
);
patchState(store, { currentPlaylist: playlist });
patchState(store, { currentPlaylist: effectivePlaylist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
@@ -16,6 +16,7 @@ import {
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { isStalkerSeriesFlag } from '../../stalker-vod.utils';
import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts';
@@ -82,13 +83,15 @@ export function withStalkerSeries() {
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService)
) => {
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
const requestDeps = {
dataService,
stalkerSession,
portalRepair,
};
return {
@@ -223,13 +226,15 @@ export function withStalkerSeries() {
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService)
) => {
const storeContext = store as typeof store &
Pick<StalkerSeriesStoreContext, 'currentPlaylist'>;
const requestDeps = {
dataService,
stalkerSession,
portalRepair,
};
return {
@@ -11,6 +11,10 @@ const PLAYLIST = {
title: 'Demo Stalker',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
// Explicit: with the flag undefined the shared predicate would fall back
// to the URL shape, classify this as a full portal and route through the
// (empty) session mock instead of the DataService mock under test.
isFullStalkerPortal: false,
} as PlaylistMeta;
const SNAPSHOT: StalkerVodSource = {
@@ -12,6 +12,7 @@ import {
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerVodSource } from '../../models';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerPortalStoreContract } from '../stalker-store.contracts';
@@ -50,13 +51,15 @@ export function withStalkerSnapshotRefresh() {
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService)
) => {
const storeContext = store as typeof store &
StalkerPortalStoreContract;
const requestDeps: StalkerRequestDeps = {
dataService,
stalkerSession,
portalRepair,
};
const findFreshRow = async (
@@ -33,6 +33,73 @@ describe('stalker-player-request.utils', () => {
};
});
it('resolves relative replies against arbitrary discovered installations', async () => {
// Discovery can persist a nested endpoint (/cp/server/load.php);
// the base must come from the endpoint's API suffix, not from a
// fixed stalker_portal|c|portal segment allowlist.
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '/media/video_5.mpg' },
});
const streamUrl = await fetchStalkerPlaybackLink(
{
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
},
{
playlist: {
...PLAYLIST,
portalUrl: 'http://demo.example/cp/server/load.php',
} as PlaylistMeta,
selectedContentType: 'vod',
cmd: '/media/source.mpg',
}
);
expect(streamUrl).toBe('http://demo.example/cp/media/video_5.mpg');
});
it('resolves relative create_link replies against the repaired endpoint', async () => {
// A lazy repair can move the endpoint while the caller still holds
// the activation-time playlist snapshot; the relative `js.cmd` must
// resolve against the endpoint that actually answered.
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: 'ffmpeg /media/video_9.mpg' },
});
// Old row: root portal.php (base path ''). Repaired endpoint lives
// under /stalker_portal — the resolver keeps that segment as the
// base for root-relative replies, so the two resolve differently.
const stalePlaylist = {
...PLAYLIST,
portalUrl: 'http://demo.example/portal.php',
} as PlaylistMeta;
const repaired = {
...stalePlaylist,
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
} as PlaylistMeta;
const streamUrl = await fetchStalkerPlaybackLink(
{
dataService: dataService as never,
stalkerSession: stalkerSession as StalkerSessionService,
portalRepair: {
applyOverride: jest.fn().mockReturnValue(repaired),
shouldAttemptRepair: jest.fn().mockReturnValue(false),
repairPortal: jest.fn().mockResolvedValue(null),
},
},
{
playlist: stalePlaylist,
selectedContentType: 'vod',
cmd: '/media/source.mpg',
}
);
expect(streamUrl).toBe(
'http://demo.example/stalker_portal/media/video_9.mpg'
);
});
it('builds create_link requests and normalizes relative portal URLs', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: { cmd: '/media/video_77.mpg' },
@@ -7,7 +7,10 @@ import {
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerContentType } from '../stalker-store.contracts';
import { executeStalkerRequest } from './stalker-request.utils';
import {
executeStalkerRequest,
type StalkerPortalRepairApi,
} from './stalker-request.utils';
export interface StalkerPlayerResponse {
js?: {
@@ -23,6 +26,7 @@ export interface StalkerPlayerResponse {
export interface StalkerPlayerRequestDeps {
dataService: DataService;
stalkerSession: StalkerSessionService;
portalRepair?: StalkerPortalRepairApi;
}
export interface StalkerPlayableItemLike extends StalkerPortalItem {
@@ -68,17 +72,28 @@ export function resolveStalkerPlaybackUrl(
try {
const portalUrlObj = new URL(portalUrl);
const pathParts = portalUrlObj.pathname.split('/');
// The installation base is the endpoint path MINUS the API suffix
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
// discovery can persist arbitrary nested installations
// (`/cp/server/load.php`), so a fixed segment allowlist would
// resolve `/media/...` against the wrong root. The legacy marker
// segments stay as the fallback for URLs that carry neither suffix.
const endpointPath = portalUrlObj.pathname;
let basePath = '';
for (let index = 0; index < pathParts.length; index += 1) {
if (
pathParts[index] === 'stalker_portal' ||
pathParts[index] === 'c' ||
pathParts[index] === 'portal'
) {
basePath = '/' + pathParts.slice(1, index + 1).join('/');
break;
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
if (apiSuffix.test(endpointPath)) {
basePath = endpointPath.replace(apiSuffix, '');
} else {
const pathParts = endpointPath.split('/');
for (let index = 0; index < pathParts.length; index += 1) {
if (
pathParts[index] === 'stalker_portal' ||
pathParts[index] === 'c' ||
pathParts[index] === 'portal'
) {
basePath = '/' + pathParts.slice(1, index + 1).join('/');
break;
}
}
}
@@ -146,8 +161,16 @@ export async function fetchStalkerPlaybackLink(
throw new Error(response.js.error);
}
// Applied AFTER the request on purpose: a lazy repair may have moved
// the endpoint during this very call, and a relative `js.cmd`
// (`/media/...`) must resolve against the endpoint that actually
// answered — not the activation-time snapshot in options.playlist.
const effectivePlaylist = deps.portalRepair
? deps.portalRepair.applyOverride(options.playlist)
: options.playlist;
const streamUrl = resolveStalkerPlaybackUrl(
options.playlist.portalUrl ?? '',
effectivePlaylist.portalUrl ?? '',
options.cmd,
response.js?.cmd ?? ''
);
@@ -1,6 +1,7 @@
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import {
executeStalkerRequest,
type StalkerPortalRepairApi,
type StalkerRequestDeps,
} from './stalker-request.utils';
@@ -76,3 +77,141 @@ describe('executeStalkerRequest', () => {
).not.toHaveBeenCalled();
});
});
describe('executeStalkerRequest portal-mode fallback', () => {
it('authenticates legacy rows with an undefined flag but a canonical URL', async () => {
// The historical import predicate persisted nothing for some rows;
// the shared predicate must fall back to the URL rule instead of
// silently skipping authentication (the #850 failure shape).
const deps = createDeps();
const playlist = {
_id: 'stalker-legacy',
title: 'Legacy row',
portalUrl: 'https://portal.example.test/server/load.php',
macAddress: 'has-mac-address',
} as PlaylistMeta;
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
expect(deps.stalkerSession.makeAuthenticatedRequest).toHaveBeenCalled();
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
});
describe('executeStalkerRequest lazy portal repair', () => {
const PLAYLIST = {
_id: 'stalker-misclassified',
title: 'Misclassified portal',
portalUrl: 'https://portal.example.test/server/load.php',
macAddress: 'has-mac-address',
isFullStalkerPortal: false,
} as PlaylistMeta;
function createRepair(
overrides: Partial<StalkerPortalRepairApi> = {}
): StalkerPortalRepairApi {
return {
applyOverride: jest.fn((playlist) => playlist),
shouldAttemptRepair: jest.fn().mockReturnValue(false),
repairPortal: jest.fn().mockResolvedValue(null),
...overrides,
} as StalkerPortalRepairApi;
}
it('retries once against the repaired configuration after an auth-failure body', async () => {
const deps = createDeps();
deps.dataService.sendIpcEvent = jest
.fn()
.mockResolvedValue('Authorization failed.');
const repaired = {
...PLAYLIST,
isFullStalkerPortal: true,
} as PlaylistMeta;
deps.portalRepair = createRepair({
shouldAttemptRepair: jest.fn(
(_playlist, failure) => failure === 'Authorization failed.'
),
repairPortal: jest.fn().mockResolvedValue(repaired),
});
const response = await executeStalkerRequest(
deps,
PLAYLIST,
CATEGORY_PARAMS
);
// The retry ran in full-portal mode against the repaired row.
expect(
deps.stalkerSession.makeAuthenticatedRequest
).toHaveBeenCalledWith(
expect.objectContaining({ isFullStalkerPortal: true }),
CATEGORY_PARAMS
);
expect(response).toEqual({ js: [] });
});
it('returns the raw response when the repair declines to change anything', async () => {
const deps = createDeps();
deps.dataService.sendIpcEvent = jest
.fn()
.mockResolvedValue('Authorization failed.');
deps.portalRepair = createRepair({
shouldAttemptRepair: jest.fn().mockReturnValue(true),
});
const response = await executeStalkerRequest(
deps,
PLAYLIST,
CATEGORY_PARAMS
);
expect(response).toBe('Authorization failed.');
expect(deps.dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
});
it('repairs after a thrown transport error and rethrows when nothing changed', async () => {
const deps = createDeps();
const notFound = { message: 'HTTP Error: Not Found', status: 404 };
deps.dataService.sendIpcEvent = jest.fn().mockRejectedValue(notFound);
const repair = createRepair({
shouldAttemptRepair: jest.fn().mockReturnValue(true),
});
deps.portalRepair = repair;
await expect(
executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS)
).rejects.toBe(notFound);
expect(repair.repairPortal).toHaveBeenCalledWith(PLAYLIST);
});
it('applies an existing override before dispatching', async () => {
const deps = createDeps();
const repaired = {
...PLAYLIST,
isFullStalkerPortal: true,
} as PlaylistMeta;
deps.portalRepair = createRepair({
applyOverride: jest.fn().mockReturnValue(repaired),
});
await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
expect(
deps.stalkerSession.makeAuthenticatedRequest
).toHaveBeenCalled();
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('never repairs on healthy responses', async () => {
const deps = createDeps();
const repair = createRepair();
deps.portalRepair = repair;
await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
expect(repair.shouldAttemptRepair).toHaveBeenCalledWith(PLAYLIST, {
js: [],
});
expect(repair.repairPortal).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,34 @@
import { DataService } from '@iptvnator/services';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import {
isFullStalkerPortalPlaylist,
Playlist,
PlaylistMeta,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
/**
* The slice of `StalkerPortalRepairService` the request pipeline needs.
* Declared here (instead of importing the service) so the service can live
* at the lib root and depend on these utils without a cycle.
*/
export interface StalkerPortalRepairApi {
/** Applies a completed repair; returns the SAME reference when no-op. */
applyOverride<T extends PlaylistMeta>(playlist: T): T;
/** Whether this failure shape justifies probing the portal at all. */
shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean;
/**
* Probes and persists a proven-different configuration; null when
* nothing may change.
*/
repairPortal(playlist: PlaylistMeta): Promise<PlaylistMeta | null>;
}
export interface StalkerRequestDeps {
dataService: DataService;
stalkerSession: StalkerSessionService;
/** Optional lazy portal-mode repair; wired by the store feature slices. */
portalRepair?: StalkerPortalRepairApi;
}
export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
@@ -14,12 +38,19 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
} as Playlist;
}
export async function executeStalkerRequest<T>(
/**
* Routes one Stalker request according to the playlist's portal mode:
* full portals go through the authenticated session (handshake + Bearer
* token + retry), token-free panels are called directly. The mode comes
* from the shared `isFullStalkerPortalPlaylist` predicate — the single
* rule every portal-mode consumer uses.
*/
async function dispatchStalkerRequest<T>(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
params: Record<string, string | number>
): Promise<T> {
if (playlist.isFullStalkerPortal) {
if (isFullStalkerPortalPlaylist(playlist)) {
return deps.stalkerSession.makeAuthenticatedRequest<T>(
toStalkerSessionPlaylist(playlist),
params
@@ -32,3 +63,48 @@ export async function executeStalkerRequest<T>(
params,
});
}
/**
* Single choke point for Stalker API calls. On top of the mode routing it
* hooks the lazy portal repair: when a request fails in a way only a wrong
* persisted endpoint/mode produces (plain-text `Authorization failed.`
* bodies on token-less requests, HTTP 404 on a vanished endpoint, terminal
* handshake failures), the repair service re-probes the portal once per
* session and — only when a different configuration is PROVEN to work —
* the request is retried against it. Healthy portals never probe.
*/
export async function executeStalkerRequest<T>(
deps: StalkerRequestDeps,
playlist: PlaylistMeta,
params: Record<string, string | number>
): Promise<T> {
const effective = deps.portalRepair
? deps.portalRepair.applyOverride(playlist)
: playlist;
try {
const response = await dispatchStalkerRequest<T>(
deps,
effective,
params
);
if (deps.portalRepair?.shouldAttemptRepair(effective, response)) {
const repaired = await deps.portalRepair.repairPortal(effective);
if (repaired) {
return dispatchStalkerRequest<T>(deps, repaired, params);
}
}
return response;
} catch (error) {
if (deps.portalRepair?.shouldAttemptRepair(effective, error)) {
const repaired = await deps.portalRepair.repairPortal(effective);
if (repaired) {
return dispatchStalkerRequest<T>(deps, repaired, params);
}
}
throw error;
}
}
@@ -13,13 +13,15 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
executeStalkerRequest,
StalkerPortalRepairService,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
@@ -97,6 +99,7 @@ export class StalkerSearchComponent {
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly portalRepair = inject(StalkerPortalRepairService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
@@ -191,30 +194,19 @@ export class StalkerSearchComponent {
...(contentType === 'vod' ? { genre: '0' } : {}),
};
// Full portals need the handshake token. The persisted flag can
// be missing on the active-playlist meta object, so fall back
// to URL detection — otherwise the portal answers
// "Authorization failed." and the search looks empty.
const isFullPortal =
(playlist as Playlist).isFullStalkerPortal ??
this.stalkerSession.isFullStalkerPortal(portalUrl);
const response = isFullPortal
? await this.stalkerSession.makeAuthenticatedRequest<StalkerSearchResponse>(
{
...(playlist as Playlist),
isFullStalkerPortal: true,
},
requestParams
)
: await this.dataService.sendIpcEvent<StalkerSearchResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: requestParams,
}
);
// executeStalkerRequest owns the portal-mode decision (shared
// predicate with URL fallback for legacy rows) and the lazy
// portal repair, so search cannot drift from the catalog paths.
const response =
await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
@@ -1290,6 +1290,9 @@ describe('PlaylistsService', () => {
store.current = target;
}
}),
dbDeletePlaylist: jest.fn(async () => {
store.current = undefined as unknown as Playlist;
}),
};
return { store, electron };
}
@@ -1479,6 +1482,87 @@ describe('PlaylistsService', () => {
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1);
});
it('serializes deletion behind queued writes so nothing resurrects the row', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-delete-race')
);
testWindow.electron = electron;
const service = createService();
// A conditional transform (the repair's write path) is queued
// when the user deletes the playlist: the delete must run AFTER
// the queued write, leaving the row deleted — not upserted back.
await Promise.all([
firstValueFrom(
service.transformPlaylistMeta(
'portal-delete-race',
(current) => ({
...current,
portalUrl: 'http://x/portal.php',
})
)
),
firstValueFrom(service.deletePlaylist('portal-delete-race')),
]);
expect(store.current).toBeUndefined();
const upsertOrder =
electron.dbUpsertAppPlaylist.mock.invocationCallOrder[0];
const deleteOrder =
electron.dbDeletePlaylist.mock.invocationCallOrder[0];
expect(deleteOrder).toBeGreaterThan(upsertOrder);
});
it('transformPlaylistMeta aborts without writing when the transform returns null', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-meta-abort')
);
testWindow.electron = electron;
const service = createService();
const writesBefore = electron.dbUpsertAppPlaylist.mock.calls.length;
const result = await firstValueFrom(
service.transformPlaylistMeta('portal-meta-abort', () => null)
);
expect(result).toBeNull();
expect(electron.dbUpsertAppPlaylist.mock.calls.length).toBe(
writesBefore
);
});
it('transformPlaylistMeta persists the transformed row and serializes with queued edits', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-meta-write')
);
testWindow.electron = electron;
const service = createService();
// A queued edit commits first; the conditional transform then
// sees ITS result — the property the Stalker portal repair
// relies on to never overwrite a user edit racing the probe.
await Promise.all([
firstValueFrom(
service.updatePlaylistMeta({
_id: 'portal-meta-write',
title: 'Edited Title',
} as never)
),
firstValueFrom(
service.transformPlaylistMeta(
'portal-meta-write',
(current) =>
current.title === 'Edited Title'
? { ...current, portalUrl: 'http://x/portal.php' }
: null
)
),
]);
expect(store.current.title).toBe('Edited Title');
expect(store.current.portalUrl).toBe('http://x/portal.php');
});
it('applies overlapping favorites transforms atomically', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-transform-race')
+57 -14
View File
@@ -22,6 +22,7 @@ import {
Channel,
DbStores,
extractStalkerItemId,
isFullStalkerPortalUrl,
isM3uRecentlyViewedItem,
M3uFavoriteChannel,
M3uRecentlyViewedItem,
@@ -254,13 +255,10 @@ export class PlaylistsService {
}
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
const isFullPortal =
portalUrl.includes('/stalker_portal') ||
portalUrl.includes('/server/load.php');
return {
...playlist,
isFullStalkerPortal: isFullPortal,
isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl),
};
}
@@ -475,17 +473,27 @@ export class PlaylistsService {
}
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
const delete$: Observable<unknown> = this.isElectronStorageAvailable
? this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return undefined;
}
// Deletion goes through the SAME per-playlist queue as every write:
// a queued mutation (e.g. the Stalker portal repair's conditional
// transform) landing after an unserialized delete would upsert the
// row back and resurrect the playlist.
const delete$: Observable<unknown> = this.serializePlaylistWrite(
playlistId,
async () => {
if (this.isElectronStorageAvailable) {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
await electron.dbDeletePlaylist(playlistId);
}
return undefined;
}
await electron.dbDeletePlaylist(playlistId);
return undefined;
})
: this.dbService.delete(DbStores.Playlists, playlistId);
return firstValueFrom(
this.dbService.delete(DbStores.Playlists, playlistId)
);
}
);
return delete$.pipe(
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
@@ -769,6 +777,41 @@ export class PlaylistsService {
});
}
/**
* Applies an atomic, conditional meta mutation: the transform runs on
* the freshly read row INSIDE the per-playlist write queue and may
* return null to abort without writing. Callers use this when the
* decision to write depends on the row's CURRENT state — e.g. the lazy
* Stalker portal repair verifying the row still carries the
* configuration it probed; a plain read-check-then-update pair would
* race a user edit already queued but not yet committed.
*/
transformPlaylistMeta(
playlistId: string,
transform: (current: Playlist) => Playlist | null
): Observable<Playlist | null> {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
const nextPlaylist = transform(playlist);
if (nextPlaylist === null) {
return null;
}
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
});
}
updateManyPlaylists(playlists: Playlist[]) {
if (playlists.length === 0) {
return of([]);
+1
View File
@@ -40,6 +40,7 @@ export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-cmd-encoding.util';
export * from './lib/stalker-portal-actions.enum';
export * from './lib/stalker-portal-mode.util';
export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
@@ -0,0 +1,75 @@
import {
isFullStalkerPortalPlaylist,
isFullStalkerPortalUrl,
} from './stalker-portal-mode.util';
describe('isFullStalkerPortalUrl', () => {
it.each([
// The canonical Ministra endpoint the old IMPORT predicate missed —
// the root cause of "portal added, no content" (#850).
'http://portal.example/server/load.php',
// /stalker_portal without a trailing slash, which the old RUNTIME
// predicate (`includes('/stalker_portal/')`) missed.
'http://portal.example/stalker_portal',
'http://portal.example/stalker_portal/c',
'http://portal.example/stalker_portal/server/load.php',
])('classifies %s as a full portal URL', (url) => {
expect(isFullStalkerPortalUrl(url)).toBe(true);
});
it.each([
'http://portal.example/portal.php',
'http://portal.example/c',
'http://portal.example',
'',
])('classifies %s as a non-full portal URL', (url) => {
expect(isFullStalkerPortalUrl(url)).toBe(false);
});
});
describe('isFullStalkerPortalPlaylist', () => {
it('trusts an explicit true flag even for a portal.php URL', () => {
// Discovery can prove a portal.php panel enforces the token; the
// observed behavior must beat the URL shape.
expect(
isFullStalkerPortalPlaylist({
isFullStalkerPortal: true,
portalUrl: 'http://portal.example/portal.php',
})
).toBe(true);
});
it('trusts an explicit false flag even for a canonical URL', () => {
expect(
isFullStalkerPortalPlaylist({
isFullStalkerPortal: false,
portalUrl: 'http://portal.example/server/load.php',
})
).toBe(false);
});
it('falls back to the portalUrl shape when the flag is undefined', () => {
expect(
isFullStalkerPortalPlaylist({
portalUrl: 'http://portal.example/server/load.php',
})
).toBe(true);
expect(
isFullStalkerPortalPlaylist({
portalUrl: 'http://portal.example/portal.php',
})
).toBe(false);
});
it('falls back to the legacy url field when portalUrl is absent', () => {
expect(
isFullStalkerPortalPlaylist({
url: 'http://portal.example/stalker_portal/c',
})
).toBe(true);
});
it('treats a playlist with no URL at all as a simple portal', () => {
expect(isFullStalkerPortalPlaylist({})).toBe(false);
});
});
@@ -0,0 +1,47 @@
/**
* Single source of truth for the "full Stalker portal" decision.
*
* A full portal is one that requires the handshake + Bearer-token auth
* lifecycle (canonical Stalker/Ministra middleware); a simple portal is a
* reseller-style `portal.php` panel that answers without any auth. The
* persisted `Playlist.isFullStalkerPortal` flag is authoritative — since
* endpoint discovery it records OBSERVED behavior, not a URL guess. The URL
* shape is only a fallback for legacy rows that predate the flag.
*
* History: three predicates used to coexist (import checked
* `/stalker_portal`, runtime checked `/stalker_portal/` OR
* `/server/load.php`, the legacy-repair migration checked a third variant)
* and their drift misclassified canonical `…/server/load.php` portals as
* token-free at import (#850, #686, #755). Every consumer must go through
* these helpers so the rule cannot fork again.
*/
/**
* URL shapes that identify a canonical (full) Stalker portal endpoint.
* Union of the historical import/runtime/migration variants: matches
* `/stalker_portal` with or without a trailing slash and the root-level
* `/server/load.php` used by Ministra installations.
*/
export function isFullStalkerPortalUrl(url: string): boolean {
return url.includes('/stalker_portal') || url.includes('/server/load.php');
}
/**
* Whether a playlist should use the full-portal auth lifecycle
* (handshake, Bearer token, watchdog).
*
* The persisted flag wins when present; rows restored from older backups can
* carry `undefined` even after the one-shot metadata migration ran, so those
* fall back to the URL rule instead of being mislabelled as legacy panels.
*/
export function isFullStalkerPortalPlaylist(playlist: {
isFullStalkerPortal?: boolean;
portalUrl?: string;
url?: string;
}): boolean {
if (playlist.isFullStalkerPortal !== undefined) {
return Boolean(playlist.isFullStalkerPortal);
}
return isFullStalkerPortalUrl(playlist.portalUrl ?? playlist.url ?? '');
}