Commit Graph
3014 Commits
Author SHA1 Message Date
dependabot[bot]and4gray 73f6eb9b17 chore(deps): bump the actions-minor-patch group with 2 updates (#1403)
* chore(deps): bump the actions-minor-patch group with 2 updates

Bumps the actions-minor-patch group with 2 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.9...v6.0.10)

Updates `github/codeql-action` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-patch
- dependency-name: github/codeql-action
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* test(packaging): allow updated pnpm action

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: 4gray <serega05@gmail.com>
2026-08-11 02:43:53 +02:00
4gray 728df1a68c fix(packaging): restore Snap desktop runtime (#1406)
* fix(packaging): restore Snap desktop runtime

* docs(packaging): publish Snap launch repair note

* fix(packaging): declare Node 22.12 floor

* docs(architecture): update SQLite pin rationale

* fix(tooling): align Node engine floor

* fix(tooling): constrain supported Node releases

* docs(architecture): correct node-abi consumer
2026-08-11 02:08:30 +02:00
dependabot[bot] 2a7d7c315e chore(deps-dev): bump the nx-version-updates group across 1 directory with 11 updates (#1401)
Bumps the nx-version-updates group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@nx/angular](https://github.com/nrwl/nx/tree/HEAD/packages/angular) | `22.7.7` | `22.7.8` |
| [@nx/devkit](https://github.com/nrwl/nx/tree/HEAD/packages/devkit) | `22.7.7` | `22.7.8` |
| [@nx/esbuild](https://github.com/nrwl/nx/tree/HEAD/packages/esbuild) | `22.7.7` | `22.7.8` |
| [@nx/eslint](https://github.com/nrwl/nx/tree/HEAD/packages/eslint) | `22.7.7` | `22.7.8` |
| [@nx/eslint-plugin](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin) | `22.7.7` | `22.7.8` |
| [@nx/jest](https://github.com/nrwl/nx/tree/HEAD/packages/jest) | `22.7.7` | `22.7.8` |
| [@nx/js](https://github.com/nrwl/nx/tree/HEAD/packages/js) | `22.7.7` | `22.7.8` |
| [@nx/playwright](https://github.com/nrwl/nx/tree/HEAD/packages/playwright) | `22.7.7` | `22.7.8` |
| [@nx/web](https://github.com/nrwl/nx/tree/HEAD/packages/web) | `22.7.7` | `22.7.8` |
| [@nx/workspace](https://github.com/nrwl/nx/tree/HEAD/packages/workspace) | `22.7.7` | `22.7.8` |
| [nx](https://github.com/nrwl/nx/tree/HEAD/packages/nx) | `22.7.7` | `22.7.8` |



Updates `@nx/angular` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/angular)

Updates `@nx/devkit` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/devkit)

Updates `@nx/esbuild` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/esbuild)

Updates `@nx/eslint` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/eslint)

Updates `@nx/eslint-plugin` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/eslint-plugin)

Updates `@nx/jest` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/jest)

Updates `@nx/js` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/js)

Updates `@nx/playwright` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/playwright)

Updates `@nx/web` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/web)

Updates `@nx/workspace` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/workspace)

Updates `nx` from 22.7.7 to 22.7.8
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.7.8/packages/nx)

---
updated-dependencies:
- dependency-name: "@nx/angular"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/devkit"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/esbuild"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/eslint"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/eslint-plugin"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/jest"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/js"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/playwright"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/web"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: "@nx/workspace"
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
- dependency-name: nx
  dependency-version: 22.7.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nx-version-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 23:05:20 +02:00
4grayandClaude Fable 5 d73551d780 fix(pwa): tolerate broken IPv6 routes and surface provider error codes (#1404)
* fix(pwa): tolerate broken IPv6 routes and surface provider error codes

Node's happy-eyeballs racing gives each address attempt only 250 ms, so a
dual-stack provider hostname behind an IPv4-only VPN namespace (Gluetun,
WireGuard) exhausts every attempt and the web backend answered with a bare
502. Raise the per-attempt budget to 2500 ms at startup — keeping the
IPv6->IPv4 fallback automatic — while an explicit
--network-family-autoselection-attempt-timeout from NODE_OPTIONS still wins.

Provider proxy failures now log the target hostname plus the underlying
Node error codes (never the URL query, which carries credentials) and
return the primary code in the error body, so the app shows
"Bad Gateway (ETIMEDOUT)" instead of an unexplained 502.

Closes #1400

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

* fix(pwa): surface proxy network codes in import/refresh toasts, document runtime contract

Codex: /parse connection failures arrive as HTTP 500 whose body carries the
new code field, but fetchFromUrl()/refreshPlaylist() mapped only the HTTP
status, so the toast stayed generic. Append the code to the translated
message (regression-covered for both flows).

Greptile: record the web-backend happy-eyeballs/diagnostics runtime contract
in CLAUDE.md's monorepo structure section.

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

* fix(pwa): drop provider reason phrases from logs, honor underscore flag spellings

Codex round 2: the HTTP reason phrase is provider-controlled and can echo
the credential-bearing request URL, so the failure log now carries only the
numeric status; and Node treats underscores and dashes interchangeably in
flag names, so the explicit-override check normalizes spelling before
matching (verified live: --network_family_autoselection_attempt_timeout
applies in both CLI and NODE_OPTIONS forms).

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

* fix(pwa): match the timeout flag as a complete NODE_OPTIONS token

Codex round 3 (P3): a raw substring search also fired on the flag text
embedded in another option's value, silently skipping the 2500 ms default.
Tokenize NODE_OPTIONS on whitespace and match the normalized option name
exactly or with '='.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:09:05 +02:00
4grayandClaude Opus 5 5ad86e094c refactor(stalker): drop unused limit state from selection feature (#1402)
* refactor(stalker): drop unused limit state from selection feature

The `limit` field and `setLimit` method lost their last consumers when
catalog pagination was replaced by infinite scroll (#1392/#1395): the
facade no longer calls setLimit and getTotalPages is gone.

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

* docs(stalker): retire pagination API from the store baseline

The compatibility baseline still told future refactors to preserve
`limit`/`setLimit` and `getTotalPages`, all three of which are gone with
the catalog pagination removal. Record them in the doc's existing
Removed section instead.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:02:41 +02:00
4gray a9e07d696f chore(deps): align Nx packages on 22.7.7 (#1396) 2026-08-10 08:19:43 +02:00
4grayandClaude Fable 5 5b211faf73 feat(remote-control): cover live collections, honest volume, status resets (#1399)
* feat(remote-control): cover live collections, honest volume, status resets

Remote control previously worked only on the three routed live layouts
(M3U player, Xtream live, Stalker ITV); playing live TV from favorites,
recently viewed, or the global collections left the mobile remote inert.

- Wire channel up/down, number select, and status publishing into the
  unified live tab, covering per-portal and global favorites/recent for
  M3U, Xtream, and Stalker; navigation follows the search-filtered,
  sorted list exactly as rendered (shared deriveVisibleFavoriteChannels)
- Treat non-live status updates as snapshots in the main process so
  stale now-playing fields are cleared instead of merged forever
- Publish a reset snapshot from every integration on destroy, so
  leaving a live view clears the remote instead of freezing it
- Report M3U supportsVolume only for built-in inline playback and no-op
  volume commands while MPV/VLC/Embedded MPV owns the audio
- Publish live status for Stalker radio (same layout, same handlers)
  and fix its channel-number lookup for non-numeric radio ids

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

* fix(remote-control): review-loop hardening for status honesty

- Make the non-live status update an authoritative reset in the main
  process: only portal survives, supportsVolume is forced false, stray
  now-playing fields from callers are dropped (Copilot review)
- Stop Stalker radio status from leaking an unrelated TV channel's EPG:
  the ITV-keyed bulk cache survives itv->radio navigation and Ministra
  ids collide across the two lists, so EPG fields publish for itv only
- Publish the reset snapshot when the M3U active channel clears in
  place (e.g. quitting external MPV), not only on route destroy
- Consider a live external session in the M3U volume gate: a
  diagnostic-recovery MPV/VLC launch owns the audio even while a web
  player is configured; republish capability on session start/end
- Share one REMOTE_CONTROL_RESET_STATUS constant across all four
  integrations instead of four hand-copied literals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

* fix(remote-control): external session outranks DASH in M3U volume gate

The managed clear-DASH MPV/VLC fallback (Shaka browser-support preflight
failure) leaves activeChannelIsDash() true while the external session
owns the audio, so the DASH shortcut bypassed the session check and kept
advertising remote volume support. The live-session check now precedes
the DASH branch; radio stays first because its inline audio element is
always mounted and remains audible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 08:18:20 +02:00
4grayandClaude Fable 5 9aeb83e515 fix(m3u): forward playlist-level custom headers to external players (#1397)
* fix(m3u): forward playlist-level custom headers to external players

The custom User-Agent/Referer/Origin stored on an M3U playlist only
reached the built-in web players (via the Electron webRequest override).
MPV/VLC and the embedded MPV player make their own HTTP requests and
received only the per-channel #EXTVLCOPT values, so a playlist-wide
custom User-Agent was silently dropped for UA-locked providers (#1221).

External launch payloads now resolve each header independently: the
channel-level #EXTVLCOPT value wins, the playlist-level value is the
fallback, blank values count as absent — matching the semantics the
unified favorites/recent stream resolver already had. Covers the
auto-launch and catch-up effects in m3u-state, the manual MPV/VLC
fallback and the embedded MPV payload in VideoPlayerComponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL

* fix(playback): send Origin as a real VLC header and cover header IPC in E2E

Review follow-ups: VLC only used the Origin value as an :http-referrer
fallback while MPV already sent it via --http-header-fields; both VLC
paths (fresh spawn and RC enqueue) now emit the same
buildHttpHeaderFields list, so a real `Origin: ...` header reaches the
provider, deduplicated against an explicit headers-map Origin. The
legacy origin-as-Referer fallback stays.

The dash-clearkey Electron E2E now asserts the new IPC contract (blank
channel-level headers arrive as undefined, not empty strings) and gains
a scenario that sets a playlist-level User-Agent through the source
editor and verifies the captured MPV fallback launch carries it across
the renderer/main IPC boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-09 23:34:03 +02:00
4grayandClaude Fable 5 e5bb127ede fix(playback): make playback keyboard shortcuts work without shared controls (#1398)
* fix(playback): make playback keyboard shortcuts work without shared controls

With the default configuration (Video.js, webPlayerSharedControls off) the
playback shortcuts advertised in the in-app help and README — Space/K, F,
arrow seek/volume, M — silently did nothing: ControlsShortcuts only exists
inside app-player-controls, which never renders on the preference-off path.

Attach a LegacyPlayerShortcuts wrapper (same arbitration and ignore rules)
in the vendor-chrome HTML5, Video.js, and ArtPlayer players, forwarding the
commands to each engine's own API. Seek stays gated on authoritative VOD
metadata plus a finite positive duration, and a visible playback diagnostic
disables the keys. The legacy ArtPlayer chrome now passes hotkey:false —
its focus-scoped vendor hotkeys ignore defaultPrevented and would
double-handle every key — with its Escape-exits-web-fullscreen behavior
restored by the new wiring.

The playback entries in the in-app shortcut help and README drop their
embedded-MPV-only qualifier, since the keys now work in every runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

* fix(playback): restore audible volume when M unmutes at zero volume

Addresses the Codex review finding on #1398: after arrowing the volume
down to zero (which mutes), M flipped muted off while leaving the volume
at 0, so the player looked unmuted but stayed silent — in all three
legacy engine adapters.

Mirror the shared controls' ControlsVolume semantics with a per-adapter
LegacyMuteMemory: muting remembers the audible volume, and unmuting while
the volume sits at zero restores it, with the same 0.5 fallback when
nothing was remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-09 23:33:12 +02:00
4gray 6ad9f3ff8a feat(stalker): discover portal endpoints on add and edit (#1391)
* feat(stalker): discover portal connection on edit

* docs(stalker): document smart endpoint discovery

* fix(stalker): make edited connection persistence atomic

* fix(stalker): serialize edit discovery

* fix(stalker): fence all edit authentication

* fix(stalker): serialize overlapping edits

* fix(stalker): hydrate playlist identity before edit

* test(stalker): await edit hydration

* fix(stalker): release abandoned edit fences

* fix(stalker): reject stale repairs before discovery

* fix(stalker): fence stale portal modes

* test(stalker): align simple portal session guard

* fix(stalker): reject superseded portal responses

* test(stalker): await settled append failure

* fix(stalker): retain abandoned auth fences

* fix(stalker): fence abandoned discovery retries

* fix(stalker): retire restored repair overrides

* fix(stalker): verify repair override retirement

* fix(stalker): preserve edit-owned repair tokens

* fix(stalker): defer repair retirement during edits

* fix(stalker): fence repair history reads

* fix(stalker): fingerprint portal URL credentials

* fix(stalker): persist submitted identity after navigation

* fix(stalker): merge late connection saves

* fix(stalker): keep edits off Xtream save path

* fix(stalker): preserve concurrent edit state

* fix(stalker): reject replaced late edit targets

* fix(stalker): guard every resolved edit write

* fix(stalker): make pwa edit guard transactional

* fix(stalker): migrate pwa flags transactionally

* fix(stalker): reserve pwa edits across tabs

* fix(stalker): coordinate playlist replacements with edit

* fix(stalker): reserve lazy repairs across tabs

* fix(stalker): drain local repair before edit lock

* fix(stalker): block queued repairs during edit drain
2026-08-09 22:34:49 +02:00
4gray ae375e0e8f fix(settings): protect unsaved edits on window close, quit, and reload (#1394) 2026-08-09 18:45:48 +02:00
4grayandClaude Fable 5 cf74f7e4a0 feat(stalker): append portal pages on scroll and drop pagination everywhere (2/2) (#1395)
* feat(stalker): append portal pages on scroll and drop pagination everywhere

Second and final PR of the pagination removal (plan:
.plans/2026-08-09-infinite-scroll-catalog.md). Stalker VOD/series grids now
feed the shared infinite-scroll contract from server-paged appends: portal
pages (server-side size, typically 14) accumulate into one deduplicated
paginatedContent list, page 1 replaces it for the skeleton, hasMoreContent
derives from accumulated length vs total_items (portals that ignore
requested page sizes still terminate), and a failed page > 1 keeps the
accumulated pages on screen with a tail retry (retryContentPage reloads the
same page; loadMore refuses to skip past an unresolved append error). The
facade splits the resource's loading flag by page — skeleton for page one,
tail spinner for appends — and keeps per-identity scroll offsets for
Stalker's INLINE detail round trips; the shared view re-arms its one-shot
restore when a detail opens in the same component instance.

The transitional supportsInfiniteScroll flag and every paged member are
deleted from PortalCatalogFacade; the shared catalog view loses the
mat-paginator, the ?page= round-trip, and the paged query-param branch. The
ITV all-channels grid becomes a client-side render window over the cached
full list (the app's last paginator), and Stalker search pages past its
first capped request via the layout's nearEnd, with a progress guard for
portals that report no usable total.

Validation: 1600 unit tests across 7 projects green (new: vod/series
append + failed-append retry, facade loading split/loadMore guards/scroll
snapshots, ITV window model, compat selector update); catalog-sorting e2e
5/5 (Stalker spec rewritten to scroll model with p>=2 network asserts and
an inline-detail spot-restore round trip; one unrelated nav-timeout flake
reproduced only under parallel machine load), search e2e 16/16, web
stalker e2e green (all-channels grid asserts the windowed count instead of
a paginator range label); lint clean; release note added and validated;
stalker-portal.md, CLAUDE.md, and ui-guidelines updated.

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

* fix(stalker): reset paging on content-type switch and never skip failed search pages

Round-1 review findings on #1395:

1. Codex P1: switching /vod -> /series with the same category id ('*' on
   both section roots) left page > 1 in place — setSelectedContentType did
   not touch paging and setSelectedCategory('*') no-ops on an unchanged id
   — so the new type's FIRST response was treated as an append onto the
   old type's accumulated list. The type setter now resets the page (and
   no-ops entirely when the type repeats, keeping detail round-trip
   restores intact).

2. Greptile P1 + Codex P2: a failed search append left searchHasMore true,
   so the next near-end advanced to page N+1 and permanently omitted the
   failed page. The search now tracks searchAppendError: a failed append
   keeps the accumulated pages and the next near-end RETRIES the same
   page; a failed fresh search (page 1) clears the previous query's cards
   instead of rendering them under the new term (Codex P2).

The page-merge/failure logic moved into applySearchPageSuccess/Failure
methods: Angular resource() never re-fires on params changes in this
repo's template-less jest harnesses (store-hosted resources do), so the
extracted methods carry the unit coverage — accumulation + dedupe,
no-total progress guard, retry-not-skip, fresh-failure clear — plus a
selection spec for the type-switch page reset. portal-stalker-feature
260, portal-stalker-data-access 464, lint clean; catalog-sorting e2e 5/5
and web stalker e2e green. search.e2e shows machine-load nav-timeout
flakes on unrelated M3U/live specs (a runaway third-party process pegs
the host CPU); CI provides the clean independent run.

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

* fix(stalker): include the portal in the search paging identity

Round-2 Codex P1 on #1395: Angular reuses the search route across
/stalker/A/search -> /stalker/B/search, and the paging identity covered
only term + filter — the page number and accumulator survived the portal
change, so the next near-end fetched portal B at the OLD page number and
appended it onto portal A's results while skipping B's first page.

The active playlist id now joins the page-reset identity, the resource
params, the stale-response guard, and the layout's near-end reset key.
Regression spec: switching the active playlist on a reused route resets
the page to 1 and rotates the scroll reset key.
portal-stalker-feature 261, lint clean.

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

* fix(stalker): end paging on no-progress appends even with a reported total

Round-3 Codex P2s on #1395 (same defect in both accumulators): the
no-progress guard only applied when the portal reported no usable
total_items. After a mid-list portal mutation, deduplication can leave
the unique list permanently shorter than the claimed total — hasMore then
stayed true forever and every scroll crossing kept requesting pages past
the end of the data.

An append that adds no unique items now ends paging in both places: the
catalog clamps totalCount to the accumulated length (hasMoreContent turns
false and the count badge reflects what is actually reachable), and the
search requires append progress in the total-backed branch exactly like
the no-total branch. Regression specs cover a duplicate page under a
larger claimed total for both. portal-stalker-data-access 465,
portal-stalker-feature 262, lint clean.

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

* fix(stalker): explicit search retry control and per-portal scroll identities

Round-4 findings on #1395:

1. Greptile P1: with the results pane parked at the bottom, repeated
   append failures exhausted the scroll auto-fill budget while the
   near-end latch stayed armed — the retry path was reachable only
   through another nearEnd event that could never fire. The search page
   now renders an explicit retry control under the results whenever an
   append has failed (same wording as the catalog grid tail), wired to
   the existing retry-same-page path, so recovery never depends on
   producing another scroll event.

2. Codex P2: the facade's saved-scroll map survives a same-config portal
   switch (the vod/series route provider is reused across /stalker/A ->
   /stalker/B), and its identity lacked the playlist — portal A's offset
   could restore onto portal B's unrelated catalog. The playlist id now
   leads the scroll identity; regression spec covers the cross-portal
   non-restore and the return restore.

portal-stalker-feature 263, lint clean.

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

* fix(stalker): restore the search results scroll after an inline detail

Round-5 Codex P2 on #1395: the search layout destroys the results
container while an inline detail is shown (showDetails) and recreates it
at offset zero — with the new multi-page accumulation a user could load
several pages, open a result far down the list, and land back at the top
on close even though the accumulated results survived.

SearchLayoutComponent now exposes a scroll handoff for hosts whose
details replace the results (getResultsScrollTop /
restoreResultsScrollTop on the container it owns), and the Stalker search
captures the offset when a detail opens and restores it one-shot after
the container is recreated on close. Regression specs cover the layout
handoff methods and the capture/restore round trip.
portal-shared-ui 90, portal-stalker-feature 264, lint clean.

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

* fix(stalker): clear accumulated search results for unsearchable portals

Round-6 Codex P2 on #1395: the loader's early returns (deleted or
malformed playlist on a reused route) predate the accumulator and
returned [] without touching it — the previous portal's cards kept
rendering under the new context once loading settled.

Every no-portal early return now goes through resetSearchAccumulator(),
which empties the accumulated list and both paging flags; the short-term
path uses it too (and now also clears a stale append error). Regression
spec covers the full reset. portal-stalker-feature 265, lint clean.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:36:59 +02:00
4gray d73acd6bfc fix(playback): clarify external player launch feedback (#1388) 2026-08-09 13:33:07 +02:00
4grayandClaude Fable 5 8442747c37 feat(xtream): replace catalog pagination with infinite scroll (1/2) (#1392)
* feat(xtream): replace catalog pagination with infinite scroll

Xtream movie/series/live catalogs now load continuously while scrolling
instead of paging. The selection store keeps a growing visibleCount render
window over the in-memory catalog (initial 50, +50 per load) plus a saved
scroll state, so opening a title and going back restores the exact spot. A
shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the
bottom (edge-triggered, mirroring search-layout) and auto-fills viewports
taller than the initial window by measuring container overflow — capped at
10 self-initiated loads per list identity, with a ResizeObserver re-check.

The shared CategoryContentViewComponent branches on the transitional
PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker
keeps its server-driven paginator and ?page= round-trip untouched until its
append lands (PR 2), after which the paged facade members and the flag are
deleted. grid-list loses its dead built-in paginator and gains tail states
(append spinner, retry-on-error) plus content-visibility on cards. The
in-portal search results reuse the search layout's nearEnd hook to window
their full result set instead of rendering it unbounded.

Validation: portal-xtream-data-access (234), portal-xtream-feature (357),
portal-catalog-feature (22), portal-shared-ui (77, incl. new directive
spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5
(new scroll-growth + spot-restore test against the large 200-item mock
scenario, Stalker paged spec unchanged); search e2e 16/16; lint green;
release note added and validated.

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

* fix(xtream): auto-fill search results and refresh the near-end latch

Review findings from #1392: the in-portal search window could stall at its
first 60-item chunk when the rendered cards did not overflow the container
— nearEnd only fired on real scroll events (Greptile P1), the search
layout's edge latch survived a result-set replacement (Codex), and the
shared directive's latch went stale after appended content moved the
bottom out of the threshold (Codex).

The search layout now drives its results container through the shared
InfiniteScrollDirective instead of a bespoke scroll handler: the measured
auto-fill reveals further chunks on tall viewports without any scroll, the
reset key (search term) and item-count changes refresh the latch, and new
nearEndHasMore/nearEndAppending inputs let consumers gate emissions.
Xtream search wires them for both modes — this also fixes the same latent
tall-viewport stall in the global search's 100-item pages — and the
Stalker search page (single capped request until PR 2) sets hasMore=false.
The directive's fill check now refreshes the latch from the measured
state, so an End-key jump straight to the new bottom is a genuine crossing
again.

New coverage: directive stale-latch regression, search-layout auto-fill +
hasMore gating, in-portal window reveal/reset in search-results. Reruns:
portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature
green; search e2e 16/16 (fresh Playwright report verified); lint clean.

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

* fix(xtream): re-measure search auto-fill on the rendered window, not the total

Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the
search layout bound the constant result-set total to the infinite-scroll
directive's item count, so once the in-portal window grew 60 -> 120 no
tracked input changed, no further overflow check was scheduled, and
results beyond 120 stayed unreachable on tall viewports.

The layout now takes an explicit nearEndRenderedCount (falling back to
resultsCount for consumers that render everything they report) and feeds
THAT to the directive. Xtream search passes the windowed slice length for
in-portal mode and the loaded-set length for global mode. Regression
specs: layout re-measures when the rendered window grows while the total
stays constant; the component exposes the rendered count following the
window. portal-shared-ui 81, portal-xtream-feature 357, lint clean.

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

* fix(xtream): include filter state in the search reset identity

Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were
keyed on the search term alone, so a filter-only transition (type filters
or the hidden-categories toggle) replaced the result set without resetting
them — a jump straight back into the threshold could be swallowed. The
search layout now accepts an explicit nearEndResetKey (defaulting to the
term); Xtream search supplies term + type filters + excludeHidden.
Regression specs: layout latch resets on an identity change without a new
term; the component identity changes on filter-only and hidden-toggle
transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean.

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

* fix(xtream): refuse global-search appends while an edited query debounces

Round-4 Codex P2 on #1392: after the reset-identity change, the layout's
auto-fill can request more results inside the 300ms search debounce. The
append then ran with the freshly edited term but the old result count as
offset, interleaving a page of the new query into the old query's visible
results until the offset-zero search landed.

An append now only continues the LAST EXECUTED search: the append guard
additionally requires the effective term to equal lastGlobalSearchTerm,
so pagination stays suppressed from the first keystroke until the fresh
search replaces the result set. Regression spec covers the mid-debounce
refusal; the two existing append specs state their precondition
explicitly. portal-xtream-feature 359, lint clean.

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

* fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop

Round-5 Codex P2s on #1392:

1. The single saved-scroll slot lost the first tab's position on a
   VOD -> Series -> VOD round trip — the series view's destroy hook
   overwrote it with series coordinates. Snapshots are now kept per
   selection identity (bounded to the 8 most recent), so a detour's save
   can never destroy another list's spot. Store API is unchanged.

2. The fixed 10-load auto-fill budget could strand items on a viewport
   large enough that ten chunks still do not overflow — with no
   scrollbar, no real scroll event can ever fire. The auto-fill now
   terminates on lack of progress instead: loads continue while they
   grow scrollHeight (until genuine overflow hands off to scroll
   events) and stop after three consecutive loads without growth, which
   only a source that reports more but renders nothing can produce.

Regression specs: VOD/Series round trip keeps both snapshots; growth
keeps filling past the old cap and stops at overflow; no-growth stalls
stop at three; reset key clears the stall guard. portal-shared-ui 83,
portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint
clean. CLAUDE.md wording updated.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:33:56 +02:00
4grayandClaude Fable 5 d5f84fb130 feat(xtream): catch-up badge for live channels with archive (#1341)
* feat(xtream): show a catch-up badge on live channels that have archive

Live channels whose provider declares playable catch-up (tv_archive=1
with a positive tv_archive_duration) now show a small history badge in
the channel sidebar next to the name and on the all-channels grid cards,
with the archive window (days) in the tooltip.

Closes #1128

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

* fix(xtream): expose the catch-up badge status to assistive technology

The mat-icon is aria-hidden and the tooltip is pointer-only, so the
badge status was invisible to keyboard and screen-reader users. Both
badge surfaces now also render the translated status as visually-hidden
text (Codex review, P2).

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

* feat(xtream): show the catch-up badge in favorites and recent lists

Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so
the shared favorites list (portal favorites/recent tabs and global
favorites) renders the same catch-up badge as the live sidebar.
Requested in PR feedback by the issue author.

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

* feat(ui): show the programme-info button in portal sidebars, stacked vertically

Adds the (i) programme-info button to the Xtream and Stalker live
sidebars and reworks the row action column: buttons stack vertically
(favorite on top, info below), so the second button costs no horizontal
space — the column is actually narrower than the previous single-button
row. The info slot is reserved (inert, visibility:hidden) while the row
has no programme, so the star never shifts when EPG data arrives.
Requested by the issue author in PR feedback.

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

* test(ui): move the archive passthrough spec out of the budget-capped file

CI lints the merge with master, where unified-live-tab.component.spec.ts
grew (#1374) to one line under the 1200 max-lines test budget — the
archive passthrough test added here tipped the merged result over. The
test moves to a focused template-less spec (plus a null-normalisation
case), leaving the main spec at master's size.

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

* fix(ui): constrain Material touch targets to the stacked button bounds

mat-icon-button keeps a 48px touch target; stacked 28px buttons
overlapped by 20px and the later sibling (programme info) stole clicks
from the lower third of the favorite star. Verified via
document.elementFromPoint before/after: the star's visual bounds now hit
the star, and clicks left of the column reach the row again instead of
the button's oversized target (Codex review).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 10:47:44 +02:00
4gray 87dc45957b chore(deps): align Angular packages on 21.2.19 (#1383)
* chore(deps): align Angular packages on 21.2.19

* docs(deps): align Vite patch references
2026-08-09 09:49:38 +02:00
4grayandClaude Fable 5 1a6af75761 feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

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

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

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

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 09:34:03 +02:00
4grayandClaude Fable 5 c95f826739 fix(playback): keep Video.js controls on the live MPEG-TS path (#1385)
Video.js was constructed without the controls option — the component relied
on a [controls] template binding on the original <video> element instead.
player.reset(), which every raw MPEG-TS/live source change goes through,
replaces the tech <video> element; the binding's target is disposed, so live
playback ended up with no native controls and a vjs-controls-disabled control
bar: no visible controls at all.

Enable controls through the Video.js constructor options in legacy mode and
drop the template binding. The Video.js control bar is a player-level child
that survives loadTech_, so it stays across resets — and the quality selector
and aspect-ratio panel buttons it hosts become reachable again.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 21:43:05 +02:00
4grayandClaude Fable 5 f58460380f fix(stalker): fall back to short EPG when bulk EPG misses the current programme (#1386)
* fix(stalker): fall back to short EPG when bulk EPG misses the current programme

Some portals' bulk get_epg_info returns only future programmes — the one
airing now is absent. The ITV channel-list previews read exclusively from
that bulk map, so every row showed 'No program information available', and
the EPG panel preferred any non-empty bulk list over the short-EPG fallback,
so it showed upcoming shows with no 'on now' entry. Recently Viewed uses
get_short_epg per channel, which is why the same channel worked there.

Panel: merge the short-EPG fallback into the bulk list instead of either/or,
and trigger the fallback whenever the bulk list has no currently airing
programme (not only when it is empty).

Rows: new throttled StalkerEpgPreviewQueue (mirroring Xtream's
EpgQueueService — bounded concurrency, inter-request spacing, 5-minute cache
including empty results, reset on playlist switch) fetches get_short_epg for
rendered channels the settled bulk guide cannot answer.

Docs: stalker-epg.md fallback contract updated accordingly.

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

* fix(stalker): bound the preview-queue burst and keep mapped channels portal-free

Review follow-up (Codex P1/P2 on #1386):

P1 — request volume: each queue sync is now capped at 30 channels (top of
the list first, where a freshly opened category is scrolled to), and the
sidebar scroll handler re-syncs (throttled, 300 ms) to fill the next gaps.
Request count now tracks how far the user scrolls instead of how many rows
are rendered; caching (including empty results) and 200 ms pacing remain.

P2 — manual mappings: a channel whose bulk record comes from a manual XMLTV
mapping never falls back to the portal short EPG. The panel path resolves
the channel's mapping before falling back and bails when an override owns
the channel; the row path excludes overridden channels from both the queue
and its cache. New store query hasItvEpgMappingOverride() exposes override
ownership; merging portal data into a mapped schedule could otherwise
surface the portal's programme — the exact thing the mapping replaces.

Docs updated (stalker-epg.md); regression tests for the cap, the mapped-
channel suppression, and the override query.

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

* fix(stalker): scope the panel EPG fallback by channel and revalidate queued previews

Review follow-up (Codex round 2 on #1386):

The panel's short-EPG fallback is now stored with the channel id it was
fetched for, and activeEpgPrograms merges it only while that channel is
still selected. A channel switch moves the selection synchronously but the
old fallback is replaced only after the new channel's EPG load runs, so the
unscoped merge mixed the previous channel's programmes into the new panel
during slow playback resolution — and left them there when resolution
failed.

The row-preview queue's completion callback now revalidates ownership: a
row claimed while the fetch was in flight — by a manual mapping override or
by bulk data — is never overwritten by the late portal response.

Both races covered by a new focused spec (verified to fail on the pre-fix
component); stalker-epg.md updated.

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

* fix(stalker): supersede the preview backlog on view exit and own empty mappings

Review follow-up (Codex round 3 on #1386):

The preview queue is now superseded when the rendered channel list empties
(a legacy-paged category switch clears it before the new channels arrive)
and when the view leaves ITV for radio — an abandoned view's backlog no
longer keeps issuing get_short_epg requests for rows that are gone.

Mapping ownership is now tracked separately from the mapped guide's
programs: a saved mapping whose XMLTV channel currently has no entries
still owns its channel, so hasItvEpgMappingOverride() keeps the portal
short-EPG fallback out — consistent with a mapping's purpose of replacing
portal data.

Both covered by regression tests (verified to fail pre-fix; the ITV-exit
test re-arms the backlog after init because the playlist effect's first run
resets the queue); stalker-epg.md updated.

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

* docs(stalker): reconcile every EPG contract reference with the row fallback

Review follow-up (Codex round 4 on #1386): the overview, get_short_epg API
notes, and data-mapping sections of stalker-epg.md still stated that rows
never issue per-row requests, and the stalker-portal skill instructed that
only the active channel may fall back — contradicting the contract this PR
establishes. All references now describe the bulk-first row previews with
the throttled short-EPG fallback queue. skills:validate passes (the skill
stays within its 500-word budget).

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

* fix(stalker): publish empty-mapping ownership reactively

Review follow-up (Codex round 5 on #1386): when the row-preview fetch
finishes before the mapping lookup, a portal programme is already rendered.
An empty mapped guide then recorded ownership only in a plain Set — no
state was patched, the preview effect never reran, and the stale portal row
survived. applyMappedItvEpg now re-patches bulkItvEpgByChannel (identical
content, new reference — deliberately) whenever it establishes new
ownership, even without programs, so the rerun sync removes the fallback
row. Store regression test extended (fails pre-fix); stalker-epg.md updated.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 21:42:42 +02:00
4grayandClaude Fable 5 92be39ef66 fix(xtream): drop URL-only season overviews and fall back to TMDB (#1382)
Xtream panels routinely fill get_series_info seasons[].overview with a
bare cover-image URL, which rendered verbatim under the season tabs.
URL-only overviews are now treated as absent (sanitizeProviderOverview),
and the lazy season enrichment stores the TMDB season overview on the
selection (tmdb_season_overviews) as the fallback description - same
cached /tv/{id}/season/{n} payload, so no extra requests. Provider text
keeps priority when it is real prose.

The enrichment write is also convergent now: the serial detail re-fires
season enrichment after every selection write, and the previous
unconditional rewrite scheduled the next cache-served run indefinitely.
A repeat run that changes nothing no longer writes.

buildSeasonDescriptions is extracted from SerialDetailsComponent, which
would otherwise cross the 400-line max-lines limit.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 11:12:59 +02:00
dependabot[bot]and4gray 7103f7e734 chore(deps): bump pnpm/action-setup from 4 to 6.0.9 (#1372)
* chore(deps): bump pnpm/action-setup from 4 to 6.0.9

Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.0.9.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6.0.9)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* test(packaging): allow pnpm action setup v6

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: 4gray <serega05@gmail.com>
2026-08-08 09:39:52 +02:00
dependabot[bot] fd29362d44 chore(deps-dev): bump electron from 41.7.2 to 41.10.3 (#1377)
Bumps [electron](https://github.com/electron/electron) from 41.7.2 to 41.10.3.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v41.7.2...v41.10.3)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.10.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 08:47:26 +02:00
4gray d9a763e77d fix(build): prevent Vite dev transform overflow (#1379)
* fix(build): prevent Vite dev transform overflow

* fix(build): preserve commented Vite URL imports
2026-08-08 08:03:09 +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
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +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
dependabot[bot] 53c318bac7 chore(deps): bump axios from 1.18.1 to 1.19.0 (#1367)
Bumps [axios](https://github.com/axios/axios) from 1.18.1 to 1.19.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.19.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:43:34 +02:00
dependabot[bot] 33e345c83f chore(deps): bump github/codeql-action in the actions-minor-patch group (#1371)
Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:42:54 +02:00
dependabot[bot] d8e3eb9219 chore(deps-dev): bump angular-eslint from 21.3.1 to 21.4.0 (#1350)
Bumps [angular-eslint](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/angular-eslint) from 21.3.1 to 21.4.0.
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/angular-eslint/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v21.4.0/packages/angular-eslint)

---
updated-dependencies:
- dependency-name: angular-eslint
  dependency-version: 21.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:42:29 +02:00
4grayandClaude Opus 5 9ff1c6ae01 feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.

Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.

get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".

Closes the identity-fields cluster: #927, #860.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:09:07 +02:00
4gray 7111942509 chore(deps): update Nx to 22.7.2 (#1365)
* chore(deps): update Nx to 22.7.2

* fix(deps): keep Nx major updates manual
2026-08-04 16:07:45 +02:00
4gray d2a83164ec feat(stalker): protocol-correct auth lifecycle (#1354) 2026-08-03 23:06:47 +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
dependabot[bot] d44948f2fa chore(deps): bump angularx-qrcode from 21.0.4 to 21.0.5 (#1351)
Bumps [angularx-qrcode](https://github.com/Cordobo/angularx-qrcode) from 21.0.4 to 21.0.5.
- [Release notes](https://github.com/Cordobo/angularx-qrcode/releases)
- [Commits](https://github.com/Cordobo/angularx-qrcode/compare/v21.0.4...v21.0.5)

---
updated-dependencies:
- dependency-name: angularx-qrcode
  dependency-version: 21.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 16:50:48 +02:00
4gray 96facd6f49 feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
2026-08-03 08:53:44 +02:00
4grayandClaude Fable 5 d3cc18dc72 fix(stalker): anchor auth-failure body detection and share it across transports (#1358)
* fix(stalker): anchor auth-failure body detection and share it across transports

The middleware's auth failures are bare plain-text bodies, but they were
matched by substring under a 200-character cap. A short page from something
in FRONT of the portal — a proxy or WAF answering
`<html><body>Access denied</body></html>` (38 characters) — therefore read as
the portal refusing authorization, which drives probe classification and the
lazy repair trigger: a portal that never answered at all could be re-probed
and reclassified. The body match is now anchored to the whole reply, with the
stock server's optional trailing counter still accepted. The structured
`js.error`/`js.msg` fields keep the wider phrase set, since a panel fills
those in deliberately.

The detection also moves to `@iptvnator/shared/interfaces`. It had to live
somewhere both transports can reach: the Electron main process is where these
bodies actually arrive and cannot import a renderer library, which is the
same reason the identity and URL builders were centralised there.
`stalker-portal-discovery.utils.ts` re-exports it, so no call site changes.

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

* fix(stalker): drop the unanchored auth-failure sweep in the session service

Anchoring the body detector closed one door and left another open. The session
service still stringified the whole response and matched an UNANCHORED
`authorization failed`, so a short page from something in FRONT of the portal
retired the token, retried, and threw `Authorization failed after retry` —
whose own message then matched the repair trigger's wide phrase set and
re-probed a portal that had refused nothing.

The shared detector already covers every real shape, including the
`js.error`/`js.msg` envelopes the sweep was also catching, so removing it
costs no coverage. Regression goes through `makeAuthenticatedRequest` rather
than the primitive, since that is where the chain actually ran.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:03:08 +02:00
4grayandClaude Opus 5 c741815b97 fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs

`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.

Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.

Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.

`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.

Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.

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

* fix(build): spawn git without a shell in the stylesheet check

`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.

Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.

Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.

Reported by Codex review on #1360.

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

* refactor(styles): move nav-list partial into ui-styles (#1361)

* fix(build): count every target of a comma-separated Sass @import

`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.

Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.

The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:18:37 +02:00
4grayandClaude Fable 5 0010dd7351 feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row

The Sources popup now anchors above its button and always fits on screen:
the overlay caps it to the space beside the chip, only the source list
scrolls so the header, search, filter chips and footer stay visible, and
it flips below the button when the space above is too small. Filter chips
(All / Available / HD+ / language) compose with the playlist search, and
"Available" runs check-all itself when nothing has been checked yet.

Expanded copy rows no longer repeat the playlist domain: each copy shows
its parsed language chip, the provider's raw stream title, and only the
tags that differ from the parent copy.

Availability checks run at most four at a time and settled verdicts are
remembered per movie and source for ten minutes, so reopening a movie no
longer re-contacts every foreign portal.

Favorites and Download become icon-only buttons with real state: a filled
heart when favorited, and a download icon that turns into a progress ring
and then a checkmark that reveals the finished file.

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

* test(portals): match the movie detail's icon-only favorite button

The Xtream movie detail's favorite control is now an icon-only button, so
the shared "add this detail to favorites" helpers no longer found it by
class. They are used against series and Stalker details too, which still
render the labeled variant, so they now select by accessible name — the
icon button carries the same label in aria-label.

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

* fix(portals): keep provider playback for a downloaded movie

Once a movie is downloaded the primary button plays the local file, and
the sources popover only exists when another playlist carries the same
film. The icon-only rework left those as the only two paths, so a
downloaded movie in a single-playlist library had no way at all to stream
the provider's copy — the labeled action that used to do it was gone.

Restores it as an icon button beside the downloaded checkmark, under the
same condition the old one used.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:27:24 +02:00
4gray 83f6a270a5 fix(dashboard): give the hero the identity the detail view matched with (#1362)
The dashboard hero showed no backdrop for items whose detail page had one.
Two independent causes, both about identity rather than the matching gate.

Stalker items never reach the `content` table, so the xtream back-fill of
`content.backdrop_url` has no equivalent for them — but the enriched backdrop
is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The
activity mappers now surface it as `backdrop_url`, where the hero already
looks first.

The hero's TMDB lookup ran on the display title alone, while the detail view
searched with the original title and the release year. Without a year
`pickConfidentMatch` requires a single exact title match, which common titles
never satisfy, and the miss lands in the negative cache under a lookup key the
detail view's hit can never be found at. The query is now built from the same
fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id`
short-circuits the search entirely.

A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every
row falls back to, and an id is valid only for its own media type. A 'tv'
verdict, reached only on positive series evidence, gets no retry back to
'movie'.

Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the
dashboard actually uses.
2026-08-02 20:31:51 +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 65f81b7110 fix(pwa): bring the Stalker transport to parity with Electron (#1348)
* fix(pwa): bring the Stalker transport to parity with Electron

The self-hosted PWA's /stalker proxy now derives its portal requests from
the same shared identity and URL builders as the Electron main process:
MAG User-Agent/X-User-Agent, full STB cookie (mac + stb_lang + timezone +
serial-derived __cfduid), SN header, JsHttpRequest=1-xml defaulting, and
the sn-only-on-get_profile rule. macAddress/token/serialNumber are control
params consumed into headers and never echoed into the portal's query
string (handshake keeps its candidate token — protocol content). The
stalker-mock-server /stalker route mirrors the new contract through the
same shared builder.

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

* test(stalker): forward the full identity header set in the mock /stalker mirror

Greptile review: the synthetic portal request kept only the cookie and
Authorization from the generated identity, so mock handlers could never
validate the SN/MAG-UA/Accept/Language/Connection headers the real proxy
sends. Forward the complete set, lowercased the way Express normalizes
incoming headers.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:51:36 +02:00
4grayandClaude Fable 5 a6186a46c8 feat(dashboard): subscription-expiry warning badge on source cards (#1342)
* feat(dashboard): warn on source cards when a portal subscription expires soon

Dashboard source cards now carry a passive expiry chip: amber "Expires in
N d" within 7 days of the subscription lapsing, error-toned "Expired" once
it has. Account details stay behind the card's ⋮ → Account info.

Xtream expirations ride on the playlist switcher's cached PortalStatusService
check — checkPortalStatusDetails() now surfaces the parsed exp_date from the
same round-trip, so the dashboard adds no extra portal calls. Stalker
expirations come from the stalkerAccountInfo snapshot persisted at import;
it lives in the playlist payload (meta rows carry payload: null), so each
Stalker source costs one full-playlist read memoized on the playlist's
update timestamp.

New i18n keys added to all 19 locales via the i18n-fill merger.

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

* fix(dashboard): address review feedback on expiry badges

- Recompute expiry badges on a minute tick so a dashboard left open
  crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2)
- Gate the expiry refresh on the recent-sources rail setting so hidden
  rails cost no portal checks or playlist reads (Codex P2)
- Move chip colors to theme-aware tokens in m3-theme.scss; both themes
  now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired
  5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2)

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

* fix(dashboard): make expiry-badge labels depend on the language signal

sourceCards previously relied on getPlaylistProvider's indirect language
read; the translate.instant() labels now read languageTick explicitly
(mirroring trendingCards). Also shift the minute tick by one so the
interval's first 0 differs from initialValue — the signal equality check
was swallowing the first heartbeat, delaying it to two minutes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:24:16 +02:00
4grayandClaude Fable 5 8f9e78ff90 fix(workspace): report a local phase while reading the cached Xtream catalog (#1345)
* fix(workspace): report a local phase while reading the cached Xtream catalog

Since #1311 the sync overlay is shown for the whole import session, but the
DB-first read path never emitted an import phase, so switching to an
already-imported Xtream playlist showed a bare "Syncing playlist" card with
no badge or description. The Electron data source now reports a
'loading-cached' phase (local-library badge, its own label and detail text)
before reading categories/content from SQLite, and the PWA data source
reports the remote loading phases on API fetches it previously swallowed.

Adds the two new i18n keys to en.json and all 18 locales.

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

* fix(portals): keep the loading-cached phase from marking a real import

The store's onPhaseChange callbacks set isImporting unconditionally, and the
initialization error path gates import-cache cleanup on that flag — so a
cancelled or failed warm SQLite read would have wiped the healthy cached
catalog and forced a full provider redownload. The shared publishImportPhase
helper now publishes 'loading-cached' as a presentation-only phase; any
remote/save phase still marks the import as running. Adds regression specs
(verified to fail against the previous behavior) in a dedicated spec file to
stay under the test max-lines limit.

Addresses Codex P1 review feedback on #1345.

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

* fix(portals): scope cancelled-import cleanup to types with remote work

A session-wide isImporting flag meant that once any content type contacted
the provider, cancelling during a later cache-only read cleared the healthy
cached catalogs of every not-yet-completed type. Cleanup now consults a
per-session set of types that actually performed remote or save work
(populated from typed phase callbacks and save-content events), so
cache-only types keep their catalogs on cancellation while genuinely
partial types are still cleared. Mixed-scenario regression spec added
(mutation-verified against the unguarded behavior).

Addresses the second Codex P1 on #1345.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:08:19 +02:00
4gray 011f322807 ci(nx): enforce synchronized dependency updates (#1343)
* ci(nx): enforce lockstep dependency versions

* ci(deps): group Nx updates explicitly

* docs(nx): document coordinated dependency updates

* fix(nx): validate peer dependency versions

* fix(nx): validate duplicate root declarations
2026-08-02 12:52:37 +02:00
4gray dac0dfdb6c docs(website): add unofficial sites post cover 2026-08-02 12:10:02 +02:00
4gray 347e3cd2f8 docs(website): define unofficial sites cover design 2026-08-02 12:10:02 +02:00
4gray d8b07ecd5b fix(website): refresh landing screenshots 2026-08-02 11:44:19 +02:00
4grayandClaude Fable 5 fc7f23b229 feat(playback): forward portal Cookie/Authorization to built-in players (#1335)
* feat(playback): forward portal Cookie/Authorization to built-in players

The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).

- request-header-overrides.service: the scoped override now carries Cookie
  and Authorization, attached only to requests on the exact stream origin,
  in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
  drop credentials fail-closed; control characters in header values are
  rejected. Chosen over session.cookies.set(): jar cookies attach only to
  credentialed requests, which would force withCredentials into every engine
  and break against the Access-Control-Allow-Origin:* IPTV panels send, and
  jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
  every built-in player: it extracts the full header set from the resolved
  playback, configures the override BEFORE handing the source over (players
  render only once the source exists), and clears the scoped layer on
  destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
  would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
  set ITV already had (they previously carried no portal headers at all);
  same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
  (isStalkerStreamCredentialSafe): same-host port changes and scheme
  upgrades keep the portal profile (the #1158 class), a foreign host or
  https->http downgrade keeps the credential-free KSPlayer profile. The
  main-process fallback context uses the same predicate so
  isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
  ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
  create_link returns a local /stream/gated/video.mp4 that 403s without the
  mac cookie + current Bearer token; new Electron e2e proves a built-in
  player actually plays it (and that the gate refuses bare requests).

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

* fix(playback): apply header override to Stalker radio, redact mock cookie log

Address Codex review feedback on #1335:

- The radio branch of the Stalker live layout renders the dedicated audio
  player, never WebPlayerViewComponent, so the resolved portal headers were
  built but never applied — an auth-gated radio stream still 403'd. The
  override sync is extracted into ElectronStreamHeadersService (single owner
  of the scoped override slot, with clear-only-while-owning semantics so a
  destroyed consumer cannot wipe a newer consumer's override), applied by
  WebPlayerViewComponent for video players and by the radio branch before
  the audio element gets its URL. The service feature-detects the bridge
  method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
  presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
  the Electron e2e covers the radio path end-to-end (bare request 403s,
  built-in audio player advances past the gate).

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

* fix(playback): claim radio header ownership before awaiting the IPC

Codex round-2 P2: leaving the radio route while the header IPC was still in
flight left the portal cookie/token installed — ngOnDestroy saw a null scope
URL (it was recorded only after the await) and could not clear the override.
Ownership is now claimed synchronously before awaiting, destroy invalidates
the pending playback continuation, and the apply's stillCurrent verdict is
honored. Regression test covers destroy-during-pending-IPC.

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

* fix(playback): carry portal headers into collection playback

Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed
resolved through StreamResolverService.resolveStalker(), which returned no
portal headers — the video path handed the header owner an empty set and
collection radio bypassed it entirely, so auth-gated streams still 403'd
from collections.

- resolveStalker() now builds the same profile as the live layout via the
  shared classifier: portal-owned streams get mac cookie/Bearer token/MAG
  UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer
  profile (both create_link results and direct radio URLs).
- UnifiedLiveTabComponent applies the scoped override for radio before the
  audio element gets its URL (ownership claimed before awaiting the IPC,
  round-2 lesson), and clears it on close and destroy.
- Regression tests: resolver header profiles for portal-host and foreign
  streams; unified tab radio apply-then-clear.

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

* fix(playback): release the radio override when a new selection mounts no player

Codex round-4 P2: after radio installed its credentials, selecting an item
that never mounts a player surface (external video playback, failed
resolution) left the old Cookie/Authorization installed — no
WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both
radio hosts (unified collection tab and the Stalker live layout, which has
the identical hole) now release the previously owned radio scope at the
start of every new selection; the slot-ownership semantics keep this a
no-op when another playback already owns the override. Regression test in
the live-layout spec pins the failed-selection path.

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

* docs(playback): state the exact override release points

Codex round-5 P2 flagged that the media 'ended' event does not clear the
scoped override while the player stays mounted. That is deliberate, not a
gap: a mounted player still owns the session — replay or a seek into an
unbuffered range must keep working against a gated stream, and clearing on
'ended' would 403 exactly the streams this PR fixes. The credentials only
ever travel to the exact origin that issued them, and every dismount path
(channel/source change, player close/destroy, radio close, playerless
selection) releases them. The security doc and the release note now say
precisely that instead of the ambiguous "cleared when playback ends".

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

* docs(playback): fit the release note back under the 400-character cap

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 10:51:12 +02:00
4grayandClaude Opus 5 6c065124ed feat(stalker): add account info dialog for Stalker portals (#1330)
* feat(stalker): add account info dialog for Stalker portals

Xtream playlists have had an account-info dialog for a while; Stalker
portals stored the same facts (login, expiry, tariff, status captured at
import) as dead weight in the database and showed them nowhere.

Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual
language: status pill, days-left/tariff/MAC hero stats, account and
portal panels. Data is cached-first — the import-time snapshot renders
instantly with a "Saved data" badge, then StalkerAccountInfoService
refreshes it: full /stalker_portal/ installations re-run
handshake+get_profile, portal.php panels are queried best-effort via
account_info/get_main_info. A failed refresh keeps the cached snapshot;
no data at all shows a retry-able error state.

Entry points are unified behind shared portal-account predicates
(isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces)
so both portal types get the same set: header playlist switcher (bottom
section + new per-row ⋮ Account info item), dashboard source card ⋮ menu,
and the command palette (now visible on stalker routes with its own
description). The header service picks the dialog by playlist type; the
per-row path works for non-active playlists and skips the session-scoped
stream counts.

Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog
already referenced (they rendered as raw keys), a get_main_info handler
in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all
19 locales.

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

* fix(stalker): unwrap nested js.account_info envelope in get_main_info

Ministra-style portals nest the account block — fetchStalkerExpireDate()
in stalker-player-request.utils already consumes exactly that shape, so
the flat-only mapper silently discarded valid responses and legacy
imports (which have no cached snapshot) got an empty account panel.

Merge nested fields over flat aliases, send the JsHttpRequest parameter
the existing get_main_info caller sends, switch the mock server to the
nested envelope so the E2E covers the realistic shape, and document the
account-info feature in CLAUDE.md (review feedback from Greptile and
Codex on #1330).

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

* test(stalker): pin account-info expiry fixture below the day boundary

Math.round on the epoch could round up half a second, putting the
fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on
CI. Floor keeps the interval strictly inside 30 days regardless of when
within the second the spec runs.

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

* refactor(stalker): address account-info review round two

Three P2s from Codex on #1330:

- Normalize the cached stalkerAccountInfo snapshot before rendering:
  the import path persists portal values verbatim, so expireDate can be
  a date string or milliseconds at runtime despite the declared number
  type. normalizeStoredStalkerAccountInfo() runs the same parsers as
  the fresh path.
- Publish the re-auth token into StalkerSessionService's cache: strict
  portals invalidate the previous token per handshake, so the dialog's
  authenticate() would otherwise strand an active portal session on a
  dead token.
- Extract the duplicated ~460-line account-dialog stylesheet into
  libs/ui/styles/_account-dialog.scss, shared by both dialogs with the
  provider accent injected via --account-dialog-accent; each consumer
  keeps only its accent and layout overrides.

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

* fix(stalker): serialize account-profile refresh with session auth

The dialog's direct authenticate() call bypassed the pendingAuth map
ensureToken() uses, so a refresh could run a second handshake while a
catalog or watchdog request was still authenticating. On strict portals
each handshake invalidates the other's token, and the later
setCachedToken() could publish an already-dead one.

Move the refresh into StalkerSessionService.refreshAccountProfile(): it
waits for any in-flight authentication, registers its own so later
callers wait for it, and republishes the resulting token. A failed
pending auth no longer aborts the refresh, and the pendingAuth entry is
only cleared when it is still this call's.

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

* fix(stalker): move pendingAuth cleanup out of the promise initializer

TS2454 under the Angular compiler: the finally block referenced
authPromise inside its own initializer, so every Electron/web production
build failed even though jest and lint accepted it. Await the promise at
the call site and retire the map entry there instead — same
only-clear-our-own-entry semantics.

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

* fix(stalker): harden account-info portal detection and expiry math

Review round four (Codex P2s on #1330):

- Fall back to the URL rule when isFullStalkerPortal is undefined: a
  playlist restored from an older backup carries no flag once the
  one-shot metadata migration has run, and it would then be sent down
  the unauthenticated legacy path and labelled a legacy panel.
- Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse
  reads it as UTC midnight, which renders as the previous day west of
  UTC and shifts the days-left boundary; timestamps carrying a time or
  offset keep standard parsing.
- Decide expiry from the raw timestamp, not the rounded counter: an
  expiry that passed less than a day ago ceil's to 0/-0, so the hero
  stat claimed "0 days left" on a dead subscription.

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

* fix(stalker): make account-profile refresh own the auth slot

Review round five (Codex P2s on #1330):

- Claim the pendingAuth slot in a loop and publish it before the first
  await. One settled promise releases every waiter at once, so a single
  pre-check let two queued refreshes both start handshakes that
  invalidate each other on strict portals.
- Retire the cached token before the handshake: ensureToken() reads
  tokenCache before pendingAuth, so catalog and watchdog requests
  starting mid-handshake were handed a token this refresh was about to
  kill instead of queueing on the slot.
- Render the portal type from the same resolver the fetch path uses, so
  a restored backup without an explicit flag is no longer labelled a
  legacy panel while authenticating as a full portal.

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

* fix(stalker): retire only the token that actually failed auth

A request dispatched with the previous token can see its authorization
failure arrive after a profile refresh has already cached a fresh one.
The retry path deleted the cache blindly, killing the fresh token and
kicking off another handshake that in turn invalidated tokens of newer
requests — cascading retries on strict portals.

makeAuthenticatedRequest() now retires the cached token only while it
still equals the token that failed; a late failure of a stale token
leaves the refreshed token in place and the retry reuses it.

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

* docs(stalker): distinguish the two no-data outcomes of the account dialog

A portal that answers but publishes no account facts renders the
ready-state "No account details" panel; only an unreachable portal
without a cached snapshot enters the error state with retry. The doc
conflated both as "error with retry" (review feedback on #1330).

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

* fix(stalker): reject negative expiry sentinels before date parsing

Portals encode unlimited/missing expiry as "-1" or "0"; the
unsigned-digit check let "-1" fall through to Date.parse, which V8
reads as January 1, 2001 — an unlimited account rendered as expired.
Signed numeric strings now take the numeric branch, whose non-positive
guard already discards them.

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

* fix(stalker): reject out-of-range calendar components in expiry dates

The multi-argument Date constructor normalizes invalid components
('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown
from a placeholder. Round-trip the parsed year/month/day and reject any
date that does not survive unchanged.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:58:03 +02:00
4grayandClaude Fable 5 c559a386df docs(release): backfill 0.23 notes for embedded MPV scaling and dock-menu fixes (#1339)
The fixes from #1206 (native view misplaced on displays scaled above 100%)
and #1207 (video jump / black bar when opening control menus) were merged
before the .changes pipeline existed and the 0.23.0 backfill missed them.
Both are user-visible and referenced from issue #1139, so they get notes.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:27:11 +02:00