14 Commits
Author SHA1 Message Date
2dfdd65f53 refactor(details): give the detail-page files real max-lines headroom (#1813)
* refactor(xtream): split the series details page into focused services

serial-details.component.ts sat at the 400-line max-lines limit and its
playback service and spec were close behind. Move cohesive concerns out
without changing behavior:

- route params, the provider-only flag and the (re)load of the addressed
  series go to SerialDetailsRouteService; the component still registers
  the effect, so effect order is unchanged
- season descriptions, posters and the TMDB season enrichment go to
  SerialDetailsSeasonsService
- actor, Similar and Discover navigation go to injectXtreamDetailNavigation,
  shared with the movie page
- the watched toggles and the episode playback payload leave
  SerialDetailsPlaybackService for SerialDetailsWatchToggles and
  buildSerialEpisodePlayback
- the spec's TestBed moves to a harness and the watched-toggle cases to
  serial-details.season-watch.spec.ts

Counted lines: component 400 -> 318, playback service 388 -> 342,
component spec 1196 -> 674.

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

* refactor(xtream): move VOD route selection and position state out of the page

vod-details-route.component.ts grew from 618 to 741 counted lines and
vod-details-playback.service.ts reached 395. Extract without changing
behavior:

- the route-derived read model (selected movie, category, catalog item,
  fallback view, multi-source identity, session and content keys) goes to
  VodDetailsSelectionService; the component keeps the same member names
- trailer state and the Similar-rail click move into the hero presenter,
  the cancel-download prompt and the progress-ring geometry into the
  downloads service, navigation into injectXtreamDetailNavigation
- stored positions (last seen, route row, guarded load) become
  VodDetailsPositionState and the external-launch bookkeeping becomes
  VodExternalLaunchClaim
- drop the unused MatTooltip import (NG8113) and eight unused imports

Counted lines: route component 741 -> 492, playback service 395 -> 344.

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

* refactor(stalker): extract series-view positions and watched toggles

stalker-series-view.component.ts grew from 1601 to 1813 counted lines and
its spec reached 1198 of 1200. Move code out verbatim:

- saved positions, their reconcile and the persist/clear writes go to the
  component-provided StalkerSeriesPositionsService; the ordering of reads
  and writes goes to StalkerSeriesPositionQueue
- episode, season and series watched toggles go to
  StalkerSeriesWatchToggleService, the batch core to
  runStalkerWatchToggleBatch
- the lazy VOD season loads go to StalkerVodSeasonEpisodeLoader

Every effect stays registered in the component constructor in its original
order; template bindings and public member names are unchanged.

The spec setup moves to a shared harness and the spillover-prefetch and
TMDB season cases to their own specs; the 184 cases of the directory are
unchanged.

Counted lines: component 1813 -> 1136, component spec 1198 -> 670.

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

* refactor(stalker): extract catalog-detail position and search paging

- stalker-catalog-detail.component.ts (397): the stored VOD position and
  its runtime updates become StalkerCatalogVodPosition, the Play/Resume
  start becomes startStalkerCatalogVodPlayback
- stalker-search.component.ts (776, baselined): the paging resource, the
  accumulated results and the parental-lock bookkeeping become
  StalkerSearchPagingController, with pure helpers in
  stalker-search-results.util.ts. The spec reaches the moved members
  through component.paging; its cases and assertions are unchanged.

Counted lines: catalog detail 397 -> 317, search 776 -> 482.

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

* refactor(ui): give season-container and vod-details real line headroom

- season-container.component.ts (396): season auto-selection and the
  seasonSelected emission move to createSeasonAutoSelectState, called at
  the same place in the constructor so effect order is unchanged; the
  episode subline becomes buildEpisodeSubline
- vod-details.component.ts (393): the cross-portal Similar loader, the
  provider-only download state and the actor/Similar route helpers move to
  sibling modules

Inputs, outputs, selectors and public members are unchanged.

Counted lines: season container 396 -> 341, vod details 393 -> 337.

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

* test(web-e2e): move Stalker portal helpers into fixtures

stalker.e2e.ts was at 1196 of 1200 counted lines. Move the mock endpoints,
scenario MACs and page helpers to stalker-portal.fixture.ts and the
embedded-series steps three tests repeated to
stalker-embedded-series.fixture.ts. Every test stays in stalker.e2e.ts in
the same order, with the same serial mode and OWNED_MACS reset.

Counted lines: 1196 -> 981.

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

* docs: follow the detail-page extractions

Name the Stalker watched-toggle and position services that now own the
batch and reconcile code, point AUTH_REJECTED_MAC and the scenario MACs at
stalker-portal.fixture.ts, and drop two stale statements about components
sitting at the max-lines cap.

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

* docs(xtream): note why the seasons service is injected last

Its TMDB enrichment effect keeps the position it had as the first effect
of the component constructor only while it is created after the other
services' effects.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 16:52:53 +02:00
6b855eb73b fix(e2e): stop mock servers from outliving Playwright runs (#1710)
* fix(e2e): stop mock servers from outliving Playwright runs

Playwright stops a webServer with a SIGKILL to the process group it
spawned, but `nx run-commands` starts its command in a detached process
group of its own. Launching the Xtream/Stalker mocks through
`pnpm nx run *-mock-server:serve` therefore left the tsx server running
(reparented to PID 1) and holding its port after every run, so the next
run failed with "…/health is already used" or silently reused a stale
server.

Every Playwright config now starts the mocks as a single
`node --import tsx apps/<mock>/src/main.ts` process with
TSX_TSCONFIG_PATH=tsconfig.base.json, which stays in Playwright's group.
A project-config spec guards all playwright*.config.ts files against
regressing to the Nx launch.

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

* fix(e2e): read sidebar categories atomically; tighten mock launch guard

- category-management: readVisibleSidebarCategoryNames read items one by
  one; when Save removed an item between isVisible() and textContent(),
  textContent() auto-waited for the gone label through the whole 15 s
  poll, so expect.poll never retried (ubuntu shard 1 failed 3/3 while the
  UI already showed "No categories available"). Take one snapshot with
  filter({ visible: true }).evaluateAll() instead.
- project-config.spec: pin which Playwright configs start which mock,
  reject any Nx form that mentions a mock server, and fail when a new
  config starts a mock without being listed (the old count check passed
  vacuously on zero matches).
- docs: state which configs start which mock instead of "every config
  starts both".

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

* revert(e2e): leave the sidebar category read race to #1728

#1728 fixes the same readVisibleSidebarCategoryNames race with a shared
helper; keeping a second copy here would only conflict. This PR stays
about mock-server lifecycle.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:57:46 +02:00
4grayandClaude Fable 5.1 4cce4acaad feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups (#1633)
* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups

Follow-ups to the season posters shipped in #1628:

- The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42
  season thumbnail at the start of each menu row that has a poster and in
  the closed trigger for the selected season, fed by a new `seasonPosters`
  input from the season container and the fullscreen episode panel. Rows
  without a poster get no placeholder, a failed image is dropped, and the
  pill row stays text-only as the design review decided.
- The fullscreen season strip's episode count uses its own
  `PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download
  manager's; all 18 locales filled through the i18n merger from their
  existing `DOWNLOADS.EPISODE_COUNT_*` translations.
- The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands
  `env` entry overrides the shell), and `main.ts` resolves `PORT`, then the
  Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now
  relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT`
  the same way.
- `resolveAutoSelectedSeason` gets a direct spec covering every branch.

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

* fix(xtream-mock): mint marketing asset URLs on the port the server bound

Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but
`marketingAssetOrigin()` still read `PORT` alone, so a run relocated only
through the alias sent every poster/backdrop/logo/episode URL to 3211.

One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`,
then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the
marketing asset origin and the demo-guide origin fallback. A spec pins the
precedence and that `marketingAssetUrl` follows the bound port.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:54:54 +02:00
4gray f40320e42e test(stalker): isolate auth e2e state by worker (#1378)
* test(stalker): isolate auth e2e state by worker

* test(stalker): bound auth e2e worker slots
2026-08-08 01:05:08 +02:00
4grayandClaude Opus 5 5e4f2ca3dd docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.

Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).

CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".

Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.

The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.

Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.

Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:49:49 +02:00
4grayandClaude Opus 5 e197409b10 fix(stalker): only mint a temporary link when the row asks for one (#1364)
* fix(stalker): only mint a temporary link when the row asks for one

`create_link` ran on every Stalker playback. The reference client — the
portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link
only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`;
otherwise it plays the static `cmd` that `get_all_channels` /
`get_ordered_list` already returned. Neither flag was read anywhere in the
codebase, so every channel paid a round trip and gained a failure point the
reference client does not have.

One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used
by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path,
and by `StreamResolverService` for Favorites/Recently Viewed. Its guards
are deliberately wider than the flags alone and can only route a row back
onto the `create_link` path: no row to read flags from, a relative or
query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a
loopback host. An episode always mints, since `series` selects it
server-side. Radio joins the same decision, so a station the portal proxies
now gets its link instead of playing a URL the portal never meant to serve.

Temporary links live ~5 s, so the audit that came with this: favorites and
recently-viewed persist the `cmd`, playback positions store ids, and the
main-process context map stores headers keyed by origin+path — none replay
a resolved URL. Downloads are the documented exception, and honouring the
flags shrinks even that, since an unflagged movie now yields a permanent
URL that survives retry.

`forced_storage` and `play_token` stay unwired, with the reasoning recorded
in the docs rather than left ambiguous.

The mock's ITV/radio rows now carry both flags, and the new
`static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows
with a playable command so the e2e can assert that NO `create_link` request
reaches the portal — verified to fail when the change is reverted, with a
companion test proving the recorder sees a link when one is due.

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

* fix(stalker): keep temporary-link flags across VOD normalization

Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a
raw portal row to an explicit whitelist, and the two flags were not on it.
It feeds both `selectedItem()` — which the VOD playback path reads as
`linkFlags` — and, through `createStalkerVodItem`, the download payload. So a
flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took
the static path, playing the portal's non-final URL instead of minting a link.

The direction of the failure is what makes it a P1: a dropped flag reads as
"no temporary link needed", so the whitelist fails OPEN. Both flags now sit on
`StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the
consequence spelled out at the normalizer so the next edit does not quietly
undo it, and specs pinning all three normalizers plus a store-level test that
a flagged VOD still mints.

Also two things from re-reading my own diff:
- The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the
  same row to `fetchStalkerPlaybackLink`, which runs that exact check again.
  Two copies of one decision is the divergence this PR exists to remove, so
  the outer call and its now-unreachable guard are gone.
- `portal-catalog-facade.ts` spells the flag shape out instead of importing
  `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared`
  may not depend on `type:data-access`/`domain:stalker`), so the obvious
  "reuse the type" cleanup does not get made and break the boundary lint.

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

* fix(stalker): authenticate before serving a static collection stream

Second Codex P1 on #1364, and a regression this PR introduced. `create_link`
was also the request that warmed the portal session. Tokens live in memory
only (`StalkerSessionService.tokenCache` is a plain Map), and the collection
header builder reads the raw `getCachedToken()`. So a cold start from global
Favorites or Recently Viewed — the portal never opened this session — took the
static path, found no token, and handed a same-host gated stream headers with
no `Authorization`: a 403 on exactly the streams the header contract exists
for. The same raw accessor cannot tell a token negotiated for a pre-edit
identity from a current one.

`StreamResolverService` now calls `ensureToken()` before building a static
playback. It is the right primitive: handshake + `get_profile` with no link
minted, identity fingerprint validated, concurrent callers deduped, and an
immediate null for simple portals — and calling it keeps this change out of
`stalker-session.service.ts`, which PR 6 (#1354) is splitting.

Best-effort by design: a static URL may point at a CDN that needs no
credentials, so a failed handshake degrades to the token-less header set
instead of costing the user their playback. Both halves are pinned by tests,
and removing the call makes the cold-start test fail.

The portal routes need no equivalent and do not get one: an item cannot be
selected before its catalog has loaded, and every catalog load authenticates.
That reasoning is now written down rather than assumed.

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

* fix(stalker): warm the session at the choke point; keep downloads authenticated

Two more Codex findings on #1364, and the first one shows my previous commit
message reasoned too broadly.

P1 — I claimed the portal routes are "structurally warm" because an item
cannot be selected before its catalog loads. That is true of the routed portal
views, but not of the global collection detail, which calls
`setCurrentPlaylist()` and `setSelectedItem()` straight from a persisted row
with no catalog load in between and then goes through the STORE playback path.
A VOD opened from Favorites on a cold start therefore still played a same-host
gated stream with no Bearer token.

Rather than extend the per-route argument, the warm-up moved to the one place
every static return passes through: `fetchStalkerPlaybackLink()` now calls the
session before short-circuiting, covering ITV, VOD, radio and downloads at
once. `StreamResolverService` keeps its own call — its static branch does not
go through that function — but both now share a single primitive,
`ensureStalkerSession()` in `stalker-request.utils.ts`, so the two routes
cannot drift on when a session is required. Still best-effort, still outside
`stalker-session.service.ts` (PR 6 territory).

P2 — downloads cannot use that escape hatch at all: the main-process stored
header allowlist is User-Agent/Origin/Referer only, no Cookie or
Authorization, so a static same-host URL 401s where a minted one worked.
`startStalkerVodDownload` now classifies the candidate with the shared
`isStalkerStreamCredentialSafe()` and withholds the row — forcing
`create_link` — for anything portal-owned. A CDN-hosted movie keeps the
permanent URL that survives retry; a portal-hosted one keeps the minted URL
that carries its own token.

Both fixes mutation-checked: each reverted change fails exactly one test.
Docs corrected, including the overreaching "structurally warm" claim.

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

* docs(stalker): record the cached-token revalidation trade-off

Codex flagged that the static path no longer self-heals a retired token, since
`ensureToken` returns a same-identity cache entry without a network call —
whereas `create_link` used to refresh it through `makeAuthenticatedRequest`'s
auth-failure retry.

The mechanism it posits does not exist on stock Stalker: per the 4.9.35
reference, handshake tokens have no TTL, and not sending the watchdog does not
invalidate auth (it only clears the admin panel's "online" flag). The real
residual vector is another device calling `get_profile` on the same MAC, which
is common enough on shared subscriptions to be worth naming.

Revalidating on every static playback would cost exactly the round trip this
change removes, so it is deliberately not done. Recorded as a known trade-off
with its mitigation (a running watchdog still self-heals within a ping cycle)
and handed to PR 6, where a refresh on an OBSERVED playback authorization
failure belongs.

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

* docs(stalker): tighten the token-revalidation trade-off wording

Greptile review feedback: the watchdog mitigation was the most important part
of that paragraph and sat behind the caveat. It now follows the MAC-sharing
vector directly, and the paragraph ends by naming what is actually left
uncovered — a same-host static stream played while no watchdog is up — so a
future reader can size the residual without re-deriving it.

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

* fix(stalker): prefer the live playlist row over a stale favorite snapshot

Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as
`item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link
branch quietly corrected for that afterwards by re-reading
`applyOverride(playlist).portalUrl`, so the row won wherever it existed, which
is what the comment right above it already promised: "when the row exists it
wins over the item's snapshot of the portal URL (a repaired endpoint must beat
a stale favorite)". The static branch I added returns before that correction,
so it shipped the stale snapshot.

Consequences after a playlist edit: a same-host static URL matching the OLD
host gets the newly negotiated token and identity headers sent to the previous
portal, and a MAC-only edit pairs the new token with the old MAC cookie —
precisely the pairing `stalkerIdentityFingerprint` exists to prevent.

Both branches now derive the coordinates once, row-first with the repair
override applied, and fall back to the item's snapshot only for a playlist
that no longer exists — which is the role `buildStalkerPlayback` already
documents for it. Mutation-checked: restoring item-first precedence fails the
new test alone.

Also documents a local-only e2e hazard found while re-running the suite:
`mode: 'serial'` orders tests within one project, but chromium/firefox/webkit
run the file concurrently against the same mock server, so one project's
beforeEach reset can drop a session another is mid-test on — which is what a
lone auth-spec failure that passes on rerun actually is. CI never sees it; the
Web E2E job runs --project=chromium alone, and that command is clean (22/22).

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

* fix(stalker): warm the session against the repaired portal configuration

Found while auditing my own static branch against the create_link path rather
than waiting for the next review round.

`executeStalkerRequest` applies the lazy-repair override on its first line, so
the create_link path always talks to the configuration a completed repair
proved good. The session warm-up I added did not: it handed `ensureToken` the
caller's pre-repair row, so a portal whose endpoint or mode had been repaired
would handshake against the configuration the repair had already rejected —
stranding the session precisely on the portals repair exists to rescue.

The override now happens inside `ensureStalkerSession`, mirroring
`executeStalkerRequest`'s first line, so every caller inherits the rule instead
of each having to remember it. Mutation-checked: dropping the override fails
the new test alone.

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

* fix(stalker): fall back to create_link when a portal-owned static url has no session

Codex P1 on #1364. `create_link` was also the request that could FAIL, and a
failure is what triggers the lazy portal repair. A playlist still misclassified
as token-free, or pointing at an unrepaired endpoint, used to self-heal on that
failure and then play; the static path issues no request, so nothing fires and
the stream just 401s.

Its suggested remedy — routing a skipped warm-up through `repairPortal()` —
cannot be taken literally: a skipped warm-up is the NORMAL case for the many
legitimately token-free reseller panels, and probing each of them on every
playback would cost far more than the round trip this PR removes.

What is decidable without a request is whether we are about to serve a stream
we already know will fail. `ensureStalkerSession` now reports whether the
session can serve credentialed playback — true for a portal needing no token
and for one holding a usable token, false for a full portal left without one —
and both static call sites act on it:

- foreign-host URL: served regardless, it never needed the session;
- portal-owned URL with a usable session: served, as before;
- portal-owned URL with no usable session: falls back to `create_link`, which
  mints a URL carrying its own token AND re-enters the only path that can
  observe a failure and repair.

That covers the unrepaired-endpoint half exactly. The misclassified-as-simple
half stays open by construction — no request means no evidence, and "simple
portal" is indistinguishable from "misclassified" without one. It belongs with
the other reactive-repair work already handed to PR 6: refresh and repair on an
OBSERVED playback authorization failure.

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

* fix(stalker): require flag evidence before trusting a row as unflagged

Two Codex findings on #1364.

P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved
before this change went through `buildStalkerSelectedVodItem`'s whitelist,
which dropped both flags, and `buildStalkerFavoritePayload` spreads that
whitelisted object. So a legacy row is flagless because WE stripped it, not
because the portal said no — and the helper was reading it as "explicitly
unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant
playing a non-final URL. There is no migration or provenance marker for those
rows.

A stock portal returns both flags on every row, so their PRESENCE is itself
the provenance signal, and it is the only one available without a refetch.
`resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be
present; absence reads as "no evidence" and routes back to `create_link`,
which is the pre-PR behaviour. This costs the optimization on panels that omit
the flags entirely — the honest price for not being able to tell them apart
from our own stripped rows.

Radio is the one documented exception. It has always played a directly usable
command without `create_link`, so a flagless radio row keeps that rather than
newly minting — a portal whose radio `create_link` never worked would
otherwise lose playback it has today. ITV and VOD have no such history and
stay conservative.

P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was
being handed to the player as a real address. Classified by range now, with a
test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it
is.

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

* fix(stalker): classify every portal-local IPv6 placeholder

Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_`
and the IPv4-mapped loopback forms slipped past the exact-name set and would
have been handed to the player as real addresses.

Checked how `URL` actually normalizes these rather than guessing at the
spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses
to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]`
arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1`
and `::`, and decodes the mapped form by its high byte, so the whole of the
mapped 127.0.0.0/8 range is covered along with the mapped unspecified address.
The dotted tail is still accepted for any engine that leaves it alone.

Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and
`[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4
decode fails five tests and nothing else.

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

* fix(stalker): normalize hostname and scheme spelling before the static verdict

Two Codex P2s on #1364, both about trusting how a portal spells things.

`http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves
identically, but `URL` keeps it for names while dropping it for IP literals
(`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read
that as a remote host and would have pointed the player at its own loopback.
Stripped before classifying.

`HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The
case-sensitive tests failed SAFE, minting a link instead, but that defeats the
contract for a portal that spells it this way, and one whose `create_link`
cannot resolve an already-playable row would break.

There were five such tests, and only one was on the new static path: the other
three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver,
where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only
reply was appended to the portal base instead of to the command. That is
pre-existing, but it is the same bug in the same shared normalizer, and fixing
only the half this PR introduced would leave exactly the divergence this PR
keeps removing. All five now go through one `hasHttpScheme()`.

The response resolver had only indirect coverage, so it gains a direct spec
alongside the static-path tests. Mutation-checked: reverting the dot strip and
the case-insensitive scheme fails ten tests and nothing else.

Also carries a docblock fix noticed on a read-through: the guard list still
pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into
`isPortalLocalHostname`, which now covers considerably more than that set.

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

* fix(stalker): normalize DNS root dots in the shared credential classifier

Codex P2 on #1364, extending the `localhost.` fix into
`isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a
portal on `portal.example` serving `https://portal.example./movie.mkv`
classified its own stream as third-party.

Wider than the download guard it was reported against: this predicate is the
single rule BOTH the renderer playback-header builder and the Electron
main-process fallback use to decide whether a stream may carry the mac cookie
and Bearer token. A portal-owned stream spelled with the root dot was getting
the credential-free profile and would 401 — pre-existing, and exactly the
"only VLC works" class this contract exists to prevent. My PR added two new
dependencies on the same predicate (the download static guard and the
portal-owned fallback), which is how it surfaced.

Both sides are normalized, so it stays symmetric, and it can only widen toward
"same host" — never toward handing credentials to a different one. A test pins
that `evil.portal.example.` is still rejected.

Also carries the authority guard found by probing the same class myself rather
than waiting for it to be reported: `http:///ch/1` has no authority and `URL`
quietly reinterprets the first path segment as the host, so a malformed
command reached the player as a nonsense address instead of going to the
portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other
exotic spellings I probed were already covered — `URL` canonicalizes `127.1`,
`2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6
normalize too.

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

* perf(stalker): classify the static url before authenticating

Codex P2 on #1364. Both static call sites awaited the session warm-up and only
then asked whether the stream needed portal credentials at all — so a movie or
channel on a foreign CDN paid for a handshake whose result was immediately
discarded.

That is not free: non-`create_link` requests carry a 15 s timeout
(`stalker.events.ts`), so a portal that is slow or offline stalled playback of
a stream the CDN would have served instantly. Cold Favorites/Recently Viewed
starts are exactly where this bites, since that is where the session is not
warm already.

Classification now runs first. Foreign host returns immediately, portal-owned
still warms and still falls back to `create_link` without a usable session.
Behaviour is otherwise unchanged; only the order and the wasted wait are gone.

Two tests moved with it: the foreign-host case now asserts the portal is not
contacted at all rather than merely not asked for a link, and the
repaired-endpoint case had been written against a foreign-host command, which
under the new ordering correctly never reaches the handshake it was meant to
be testing — it uses a portal-owned command now.

Mutation-checked: restoring warm-before-classify fails the foreign-host test
alone.

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

* test(stalker): repoint two handshake tests at the path they claim to cover

Self-audit, prompted by the previous round: the reorder exposed one test that
was asserting through a path it no longer reached, so I checked the rest of
that class rather than assume it was the only one. Two more had the same
defect, both mine.

`still returns the static url when the handshake fails` (both specs) mocked
`ensureToken` to reject, but used a FOREIGN-host command. Now that
classification runs before authentication, that command returns before the
handshake is ever attempted — the rejection was never exercised and the test
passed on the early return instead of the mechanism in its name. Worse, the
foreign case is already covered by the test added alongside the reorder, so
these were asserting nothing new.

Both now use a portal-owned command, which is what actually reaches the
handshake, and assert what a throw really produces: `ensureStalkerSession`
swallows it, the verdict is false, and the row falls back to `create_link`
rather than being served as a known 401. Each asserts `ensureToken` was in
fact called, so neither can silently drift back into testing an early return.

Docs corrected with them: the "best-effort degrades to the token-less header
set" wording described behaviour the reorder removed. A foreign-host URL is
now returned before any handshake, and a failed one routes to `create_link`.

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

* test(stalker): make the simple-portal skip test prove portal mode

Fourth test found passing through the wrong exit, from auditing all ten in the
block rather than waiting to trip over another one.

`skips the handshake for a simple portal` used a foreign-host command, so the
classification step returned before the warm-up was reached. `ensureToken` was
indeed not called — but because the host was foreign, not because the portal
was simple, and the assertion could not tell those apart. The command is now
portal-owned, so the skip can only come from the mode, and the test also pins
the returned URL and that no request was made.

Mutation-checked properly this time: removing the simple-portal early return
from `ensureStalkerSession` now fails this test. Under the old command it
would not have.

Also records the pattern where the next person will meet it. The decision
chain has several exits — no flag evidence, unresolvable command, `series`
set, foreign host, unusable session — and more than one can satisfy the same
assertion, so a foreign-host command silently stands in for "simple portal" or
"handshake failed". Mutation testing does not catch that class: it proves a
test is coupled to its target, not that it reached the mechanism it names.

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

* fix(stalker): key the radio fallback on flag evidence, not snapshot presence

Codex P2 on #1364, and a divergence I introduced myself.

`withStalkerPlayer`'s radio branch checks `hasStalkerLinkFlagEvidence(item)`
before synthesizing the zero flags. `StreamResolverService` used `??`, which
only falls back when the snapshot is absent entirely. A radio Favorite or
Recent row persisted before the flags were carried HAS a snapshot — the old
whitelist just stripped the flags out of it — so the `??` selected that
flagless object, the helper found no evidence, and the collection route began
minting for exactly the rows that used to play directly. That breaks portals
whose radio `create_link` is unsupported, which is the case the radio
exception exists for.

The two paths now apply the identical rule. The divergence came from fixing
them in different rounds and is precisely the class this PR keeps closing, so
the comment on each side now points at the other.

The existing radio test carries no `stalkerItem` at all, so it exercises the
missing-snapshot arm and stayed green throughout — the same "passes through a
different exit" pattern documented in the section above. The new test supplies
a present-but-flagless snapshot. Mutation-checked: restoring the presence
check fails it alone.

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

* fix(stalker): treat every reserved localhost name as portal-local

Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves
`localhost` AND every name ending in `.localhost` for the loopback interface,
and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the
player's own machine instead of being sent to the portal to resolve.

Closed the class rather than adding one more name: the suffix is matched, and
`localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias
for 127.0.0.1 on most Linux systems. Together with the earlier rounds the
predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`,
`127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to
hex, and a terminal DNS root dot on any of them.

Only the suffix is reserved, so the guard must not over-match: tests pin that
`localhost.cdn.example` and `notlocalhost` remain ordinary routable names and
keep playing statically. Mutation-checked: dropping the suffix rule and the
localdomain alias fails four tests and nothing else.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:36:56 +02:00
4grayandClaude Fable 5 b92503feae feat(stalker): endpoint probing + behavior-based portal mode with lazy repair (#1344)
* feat(stalker): endpoint probing + behavior-based portal mode with lazy repair

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

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

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

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

Fixes #850, #686, #755.

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

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

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

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

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

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

Review round 2 (Codex P2 x2):

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

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

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

Review round 3 (Codex P1 x2, P2):

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

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

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

Review round 4 (Codex P1 + P2):

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

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

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

Review round 5 (Codex P2 x3):

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

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

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

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

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

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

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

Review round 7 (Greptile P1 + Codex P2):

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

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

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

Review round 8 (Greptile 4/5 concern):

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

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

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

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

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

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

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

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

Review round 10 (Greptile 4/5 concern):

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

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

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

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

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

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

* fix(stalker): collision-proof session fingerprints

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

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

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

Review round 13 (Codex P1 docs + P2):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review round 20 (Codex P2 x4):

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

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

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

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

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

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

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

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

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

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

Review round 23 (Codex P2 + P1 docs):

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

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

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

Review round 24 (Codex P1 + P2):

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

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

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

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

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

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

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

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

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

Review round 27 (Codex P2 + P1 docs):

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

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

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

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

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

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

Review round 29 (Codex P2 + P1 docs):

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:01:45 +02:00
4grayandClaude Fable 5 297e9fbef8 fix(stalker): send cmd in the reference MAG wire format (#1334)
* fix(stalker): send cmd in the reference MAG wire format

A real MAG sends cmd unencoded and the portal decodes its query exactly
once, so a cmd that already contains percent sequences (%3A tokens,
pre-encoded path segments) must pass through untouched. The previous
encodeURIComponent transport (2c032cd3c, 0.22) double-encoded such cmds
(%3A -> %253A): strict portals and reseller panels that compare cmd
literally, and stock create_link handlers matching the decoded value,
saw a different string than a real STB sends.

The new shared encodeStalkerCmdValue() reproduces the reference wire
bytes: % passes through verbatim, characters the WHATWG URL serializer
keeps raw in a query stay raw (so the bytes survive the axios/new URL
transport unchanged), and everything else is percent-encoded. That
preserves the 0.22 injection protection - &, # (and ; for PHP setups
with a ; argument separator) inside cmd cannot append or truncate query
parameters; they decode back to the original byte server-side.

Both transports now share the format: the Electron query builder is
extracted to buildStalkerRequestUrl() and the web-backend /stalker
proxy appends cmd to the portal URL itself instead of letting axios
turn slashes into %2F (the opposite divergence).

Also unifies the two divergent response-side cmd normalizers: the
cross-portal collection resolver now uses the Stalker store's
normalizeStalkerPlaybackCommand/resolveStalkerPlaybackUrl, so playing
from Favorites/global collections resolves relative (/media/...) and
query-only (?token=...) create_link replies against the portal base
instead of handing the player a bare relative path.

The mock portal's create_link response gains mock-only cmd_received/
query_keys_received diagnostics; a new Electron e2e pins the contract
end-to-end (single decode, injection blocked). Unit corpus tests cover
the encoder, the Electron builder, the web-backend proxy, and the
resolver.

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

* fix(pwa): sanitize portal URL before appending stalker cmd

A registered portal URL carrying a fragment would swallow the appended
cmd (everything after # is never transmitted), and a trailing bare '?'
produced '??cmd='. Drop the hash and pick the separator from the
sanitized href before appending. Flagged by Greptile/Codex on #1334.

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

* refactor(pwa): make stalker cmd append visibly query-only for CodeQL

Rebuild the /stalker request URL through the URL object and concatenate
the encoded cmd strictly behind a literal '?', so static analysis can
see the tainted value never reaches host or path (js/request-forgery
alert on the previous separator ternary). Behavior unchanged; the
fragment/bare-'?' regression tests still pin the wire format.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 07:38:11 +02:00
4grayandClaude Fable 5 3dbfefa3d8 test(stalker): enforce portal auth in the mock and cover the full-portal flow (#1324)
* test(stalker): enforce portal auth in the mock and cover the full-portal flow

The mock server implemented neither get_profile nor get_events and
validated no auth at all, and the e2e suite imported the portal through
/portal.php — which the app classifies as a *simple* portal. The entire
authenticated branch (handshake, token, watchdog, re-auth) therefore had
zero coverage, right before a series of PRs that reworks exactly that.

Mock server:
- serve the canonical /stalker_portal/server/load.php endpoint, which
  enforces the Bearer token and the Infomir MAC format like the real
  middleware; /portal.php stays tolerant so the existing suite keeps
  covering the simple-portal branch
- auth-store.ts models the parts of Stalker 4.9.35 a client can get
  wrong: plain-text auth failures with HTTP 200, a handshake that is not
  yet a session, idempotent token re-presentation, and permanent
  device_id pinning (including the blank-after-pinned lockout)
- add get_profile (status 0/1/2, device conflict, block_msg) and the
  get_events watchdog; profile advertises watchdog_timeout/timeslot
- new login-required scenario MAC and POST /invalidate-session so tests
  can force a mid-session token loss
- the /stalker proxy route now forwards the token as a Bearer header and
  wraps auth failures in the { payload } envelope, matching web-backend

Also moves extractMac into request-mac.ts: importing it from the
categories handler dragged the whole data generator into any consumer,
which broke unit tests on the workspace alias.

E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile
precedes content, that content requests carry the token while the
handshake does not, that the plain-text failure body is never rendered,
and that the client re-authenticates after the portal drops the session.

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

* fix(mock): address CodeQL findings in the new portal auth code

Two genuine defects in the code this PR added:
- readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on
  "bearer" followed by a long run of spaces; require the token to start
  with a non-space character instead
- the /stalker proxy route read query params as strings without
  narrowing, so a repeated key (?url=a&url=b) arrives as an array and
  String.prototype.includes silently changes meaning

The remaining three alerts (missing rate limiting x2, sensitive data in
a GET query) are web-service hygiene rules aimed at internet-facing
services. The mock servers bind to localhost, serve fabricated data,
ship in no artifact, and deliberately mirror the real backend proxy's
token-in-query contract; a rate limiter would break the E2E suite that
hammers them. Exclude only those two apps from analysis via a documented
CodeQL config; every shipped path keeps full coverage.

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

* fix(mock): tighten portal-auth fidelity per review

Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid:

- adoptToken only accepts tokens the mock actually issued (or the
  already-bound one). The stock server pins any presented Bearer —
  handshake is stateless there — but a fixture that does the same
  cannot catch a client with a broken token pipeline; documented as a
  deliberate strictness divergence.
- /invalidate-session clears tokens but keeps pinned device identity:
  losing a token never unpins device_id on a real portal, so changed
  identity after re-auth must still hit the device-conflict branch.
- The login-required scenario gates on actual do_auth completion
  instead of auth_second_step: the app sends auth_second_step=1 on its
  very first get_profile, so the parameter check was trivially
  bypassed and the status-2 flow never exercised. do_auth is now the
  faithful boolean step (non-empty credentials -> {js:true}, recorded;
  empty -> {js:false}).
- /server/load.php — the second URL shape isFullStalkerPortal
  recognizes — is now served and enforced, directly and through the
  /stalker proxy predicate, so full-portal tests cannot silently fall
  into the tolerant branch.

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

* test(stalker): prove content actually reloads after re-authentication

Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong
reason" class): the re-auth test only polled for a fresh handshake and
a negative body-text assertion, both of which pass even if the original
content request is never replayed or stays unauthorized. Capture the
content token from the initial import, then assert a post-invalidation
CONTENT request goes out under a DIFFERENT token and that the ITV
categories actually render — the mock only answers content for an
adopted token, so this proves the new token round-tripped through
get_profile. Verified against a live mock that the token genuinely
rotates (old token -> "Authorization failed.", new token -> content).

Also documents the second Codex P2: the mock is deliberately strict on
/server/load.php (a real portal enforces auth there); the import dialog
vs session predicate divergence is a separate app bug the strict
endpoint will let a later PR cover.

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

* test(stalker): serialize the portal specs and bind mocks to loopback

Review follow-up on #1324 (Codex, 4xP2):

- Parallel-reset race: under the workspace `fullyParallel` preset the new
  auth file ran concurrently with stalker.e2e.ts against one shared mock
  process, and each `beforeEach` wiped global state (sessions, favorites)
  mid-assertion in the other. Reproduced locally: both suites green in
  isolation, two failures when run together. Merged the auth tests into
  stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also
  removes the pre-existing race between that file's own tests. 19/19
  green across three consecutive runs.
- Watchdog was recorded but never asserted, so the suite would stay green
  if the full-portal workflow stopped pinging or dropped its token —
  `sendWatchdogPing` swallows failures. Now polls for an authenticated
  `get_events`.
- Both mock servers listened on every interface (stalker: `listen(PORT)`
  with no host; xtream: an explicit `0.0.0.0` default), which made the
  CodeQL exclusion's "binds to localhost" rationale untrue. Both now
  default to `127.0.0.1` with a `HOST` opt-in, and the config comment
  states plainly what the directory-wide ignore trades away.
- Documented that the login-required scenario is HTTP-level only for now:
  the client's `do_auth` path is dormant and sends empty credentials, so
  the fixture is waiting on that client-side work rather than claiming
  end-to-end coverage.

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

* test(stalker): force a real auth failure before asserting it stays hidden

Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid:

- The "never surfaces the plain-text auth failure" test only performed a
  successful import, so its negative body assertions were vacuous. It now
  imports with a MAC outside the Infomir OUI: the strict endpoint answers
  get_profile with a bare {status:1}, no token is ever adopted, and every
  content request keeps returning "Authorization failed." Unlike an
  invalidated session this cannot be repaired by the client retry, so the
  failure is genuinely observed (asserted directly against the proxy) and
  only then checked for not leaking into the UI.
- docs/architecture/xtream-mock-server.md still documented the wildcard
  bind that 4b31f7167 replaced with a loopback default; it now states the
  new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container.
- Removed a dangling "Known app-side gap: the" fragment left in the
  stalker mock README.

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

* fix(mock): scope /reset by MAC so parallel specs stop wiping each other

The re-authentication test passed locally but failed all three CI
attempts: no request carried a token, because self-hosted.e2e.ts issues
a GLOBAL `POST /reset` against the same mock from a parallel Playwright
worker, destroying the session mid-import. Running only stalker.e2e.ts
locally never triggered it.

Serializing within one file (4b31f7167) could not fix this — the
interference is between files. Mock state is per-MAC, so `/reset` now
accepts `?macAddress=` and clears only that MAC's data, favorites,
session and watchdog counters; the unscoped form is kept for callers
that own the whole server. Both spec files now reset only the MACs they
own, so no worker can disturb another.

Verified: a scoped reset of one MAC leaves another MAC's session intact
(and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together
23/23 green — the combination that reproduced the CI failure.

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

* test(e2e): scope the last global Stalker reset in sources-pwa helpers

Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed
resetPwaMockServers, which still wiped the whole Stalker fixture from a
third spec file. Scope it to the two MACs this suite owns.

The auth tests use dedicated MACs no sibling touches, so portal sessions
— the fragile state — can no longer be cleared by a parallel worker.
Content MACs still overlap between files, which is harmless: that data is
regenerated deterministically from the same seed.

Verified with the full interfering set running together:
stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green.

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

* test(stalker): await the first authenticated content request

The re-auth test kept failing on CI (3/3 attempts) with an undefined
token while passing locally. My earlier diagnosis — a sibling spec's
global /reset — was wrong: the failure survived the scoped-reset fix.

Real cause is a race in the test itself. `addFullStalkerPortal` only
awaits the route change, so on a slower runner the first authenticated
content request has not been recorded yet when the token is read; the
sibling test that passes happens to await `.category-item` first. Poll
for a content request carrying a token before capturing it.

The scoped-reset work stands on its own merits (cross-file resets were
a real hazard), it just was not what broke this test.

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

* test(stalker): drop serial mode, batch resets, cover the auth handlers

Review round on a44f8135f plus a stability regression I introduced.

Codex, both valid:
- The proxy route stripped `token` from the forwarded query, so
  `handshake` never saw a presented token and the idempotent-handshake
  behaviour I documented was unreachable through the PWA path. The real
  backend forwards every param except `targetId` *and* sets the header;
  match it. Verified through the proxy: re-handshake now returns the
  same token with not_valid 0.
- The login-required scenario had no committed test, so the README claim
  was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth ->
  profile, MAC-format rejection, device conflict, idempotent handshake,
  watchdog). Handlers are called directly because the dispatcher pulls in
  the faker-based generator, which this project's Jest cannot transform.
- Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of
  sharing the Stalker suite's, so no reset can reach another suite's
  state at all.

Stability: a baseline run of master passed 23/23 first try while this
branch failed a different test each run, so the flakiness was mine.
`mode: 'serial'` was a stand-in for isolation that per-MAC scoping now
provides properly, and it amplified every flake by aborting the rest of
the file; removed. `beforeEach` also fired seven sequential resets — the
endpoint now accepts repeated `macAddress` params so a suite clears all
of its MACs in one request. Added a retrying POST helper after an
ECONNRESET on a control call.

Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each; 28 mock unit tests; lint clean.

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

* test(stalker): restore serial mode for the shared-scenario file

Review follow-up (Codex P2), valid: the previous commit removed
`mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS,
so under fullyParallel one test in this file could clear another's data
or session mid-run.

Of the two suggested fixes, serialize rather than give each test its own
MAC: the tests here are written against scenario fixtures (default,
minimal, embedded-series) whose shapes the assertions encode, so a MAC
per test would mean inventing a scenario per test and rewriting
pre-existing assertions. Cross-file isolation stays with the disjoint
sibling MAC range, which is what serial was wrongly standing in for
before.

The header now states both levels explicitly so the next reader does not
undo one of them.

Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 21:52:09 +02:00
4gray e55d55b47f feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.

Supporting changes made while getting it green:

- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
  Tier A: it is fictional fixture data, so a statement percentage over it means
  nothing, and a Tier A entry would pull it into the merged coverage map and the
  ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
  of its own — it is already Tier C and the Tier B/C runner falls back to
  `pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
  `tools/release/capture-app-driver.ts`:
  - VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
    now lists the showcase movies first, so 62000-62002 became Black Harbor, The
    Paper Astronaut and Summer Static while the dashboard seeding still mapped
    those ids to the previous titles' backdrops.
  - the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
    tsconfig.base.json`, so the mock could not resolve
    `@iptvnator/shared/marketing-fixtures` and the capture never started. Both
    mock projects' own serve targets already passed the flag.
2026-07-27 08:10:57 +02:00
4grayandClaude Fable 5 59c15493a7 docs: sync CLAUDE.md, AGENTS.md and architecture docs with actual code
Full audit of CLAUDE.md, AGENTS.md, README.md and docs/architecture/
against the codebase; every fix is backed by current code:

- remove documented-but-unimplemented IPTVNATOR_DISABLE_HARDWARE_ACCELERATION
  flag (no reads anywhere in apps/, libs/, tools/)
- CLAUDE.md: add epg_channel_mappings to the schema table list
- m3u-playlist-module: *-tab dirs -> *-view (+recent-view), selectActivePlaylist,
  real PlaylistState shape, ChannelEpgMetadata instead of removed EnrichedChannel,
  actual /workspace/playlists routes, per-view outputs, live-epg-panel-state key
- workspace-dashboard: per-rail Settings.dashboardRails toggles, three missing
  rails in the diagram, split live-favorites/recent-live rails,
  welcome-dashboard empty-state type, RECENTLY_WATCHED_LIVE_TV title key
- stalker-portal: CategoryContentViewComponent for vod/series, collection-route
  components for favorites/recent, corrected series-view/favorites-button paths,
  actor/:personId route, epg panel selectors
- category-management: reloadCategories lives in with-content.feature.ts,
  workspace-context-panel owns the dialog, XtreamPendingRestoreService flow
- stalker-mock-server (+app README): scenario-seeded faker, resetAll() clears
  content cache too, ordinal season episode ids, handlers/ dir location
- sqlite-db-worker: cancellation shipped (drop from out-of-scope), full
  operations module list
- portal-detail-navigation: replace three removed component paths
- tmdb-metadata-enrichment: details cache keys are id:<tmdbId>|v2
- electron-security: CSP frame-src youtube-nocookie exception,
  sandbox: !frameCopyExperiment nuance
- download-manager: libs/portal/xtream instead of xtream-electron folder,
  data-driven downloads nav, drop removed app-search-result-item note
- playlist-backup-restore: settings-backup facade owns the import handoff
- workspace-shell: functional workspaceEntryRedirect, playlists route children
- iptvnator-ui-guidelines: EPG card radius 11px, detail-view mixin is `base`
- embedded-mpv-native, player-controls-contract: minor precision fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:16:33 +02:00
4gray ab905af285 feat(stalker): add radio support
Entire-Checkpoint: f957cd9849e0
2026-05-09 01:28:39 +02:00
4gray 7cc25380dd docs: update EPG documentation to reflect new bulk handling and fallback logic 2026-04-11 10:44:18 +02:00
4gray 95c32708ee test: add stalker-mock-server and integrate with e2e tests
- Introduced `stalker-mock-server` with TypeScript configuration for local development and testing.
- Updated `playwright.config.ts` to run both Angular app and mock server in parallel during e2e tests.
- Created comprehensive e2e tests for Stalker portal functionality, including health checks, portal addition, and content loading.
- Added detailed architecture documentation for both `stalker-mock-server` and `xtream-mock-server`, outlining design decisions, data flow, and API protocols.
- Implemented `xtream-mock-server` to simulate Xtream Codes API for local development and testing, with corresponding e2e tests.
- Enhanced test isolation by resetting mock server state before each test run.
2026-02-21 08:44:27 +01:00