Commit Graph
2756 Commits
Author SHA1 Message Date
50871e581f feat(playback): add quality selection to shared player controls (#1470)
* feat(playback): add quality selection to shared player controls

Adds a per-session video quality menu (Auto + "1080p"-style levels) to the
shared player-controls layer, mirroring the audio-track pattern:

- Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state,
  setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR.
- hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with
  list-index ids, smooth switching through nextLevel, selection read from
  manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED,
  LEVEL_SWITCHED.
- Shaka (DASH): variant tracks filtered to the active audio language, ABR
  disabled before selectVariantTrack; manual state keyed to the exact player
  instance so a session restart never shows a stale selection.
- Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual =
  exactly one enabled level, auto = all enabled, derived statelessly).
- Embedded MPV and external players report the capability false.

The capability derives from the manifest (advertised only for >1 video
rendition), nothing persists to Settings, and the menu rides the default-off
webPlayerSharedControls rollout gate. Labels come from one shared helper so
all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to
all 19 i18n files.

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

* fix(playback): pin DASH quality candidates to the active audio stream

Review findings on #1470:

- Shaka quality candidates now match the active variant's exact audioId
  (language fallback only when Shaka reports none), so a DASH manifest with
  same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no
  longer switch the audio track or show duplicate levels when a quality is
  picked. Regression test added.
- Mirror the quality-selection contract into AGENTS.md's Shared Player
  Controls section, which must stay in sync with CLAUDE.md.

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

* fix(playback): track Video.js manual quality intent explicitly

Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off
itself when it temporarily excludes failing renditions, so inferring the
manual/auto mode from the enabled count could report a manual selection the
user never made once exclusions leave a single survivor.

VjsQualityLevels now records the picked level object as explicit manual
intent: error exclusions read as auto, a picked level that leaves the list
reverts to auto, and the bridge resets the intent on every new source.
Regression tests added.

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

* fix(playback): re-enable surviving renditions when the picked level is removed

Codex follow-up on #1470: dropping manual intent when the picked
QualityLevel leaves the list reverted the UI to auto but left the surviving
renditions disabled by the earlier manual pick, pinning VHS with no
selectable rendition. Reverting to auto now re-enables every remaining
level, both on the removal event and lazily from the state read.
Regression tests added.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:39:39 +02:00
242640e8c6 chore(deps): bump ngx-indexed-db from 21.0.0 to 22.0.0 (#1472)
Coordinated replacement for the Dependabot branch: the bot updated only the
root package.json, leaving the ^21 specifier in libs/shared/interfaces, which
failed the @nx/dependency-checks lint rule.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:26:02 +02:00
dependabot[bot] 96e235cd90 chore(deps-dev): bump @angular/cli from 21.2.19 to 21.2.21 (#1462)
Bumps [@angular/cli](https://github.com/angular/angular-cli) from 21.2.19 to 21.2.21.
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/v21.2.19...v21.2.21)

---
updated-dependencies:
- dependency-name: "@angular/cli"
  dependency-version: 21.2.21
  dependency-type: direct:development
  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-23 01:56:31 +02:00
dependabot[bot] c1ad4672c7 chore(deps-dev): bump @angular-devkit/schematics from 21.2.19 to 21.2.21 (#1460)
Bumps [@angular-devkit/schematics](https://github.com/angular/angular-cli) from 21.2.19 to 21.2.21.
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/v21.2.19...v21.2.21)

---
updated-dependencies:
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 21.2.21
  dependency-type: direct:development
  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-23 01:15:43 +02:00
dependabot[bot] 82486220ac chore(deps): bump github/codeql-action in the actions-minor-patch group (#1463)
Bumps the actions-minor-patch group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.37.6 to 4.37.7
- [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.6...v4.37.7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  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-22 23:29:00 +02:00
dependabot[bot] ca301d5399 chore(deps): bump the npm-minor-patch group with 8 updates (#1459)
Bumps the npm-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [hls.js](https://github.com/video-dev/hls.js) | `1.6.17` | `1.7.0` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.66.0` | `8.67.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.66.0` | `8.67.0` |
| [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.66.0` | `8.67.0` |
| [esbuild](https://github.com/evanw/esbuild) | `0.28.1` | `0.28.2` |
| [ng-mocks](https://github.com/help-me-mom/ng-mocks) | `14.16.1` | `14.17.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.23.11` | `4.23.12` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.66.0` | `8.67.0` |


Updates `hls.js` from 1.6.17 to 1.7.0
- [Release notes](https://github.com/video-dev/hls.js/releases)
- [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md)
- [Commits](https://github.com/video-dev/hls.js/compare/v1.6.17...v1.7.0)

Updates `@typescript-eslint/eslint-plugin` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/utils)

Updates `esbuild` from 0.28.1 to 0.28.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.1...v0.28.2)

Updates `ng-mocks` from 14.16.1 to 14.17.1
- [Release notes](https://github.com/help-me-mom/ng-mocks/releases)
- [Changelog](https://github.com/help-me-mom/ng-mocks/blob/main/CHANGELOG.md)
- [Commits](https://github.com/help-me-mom/ng-mocks/compare/v14.16.1...v14.17.1)

Updates `tsx` from 4.23.11 to 4.23.12
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.12)

Updates `typescript-eslint` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: hls.js
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: esbuild
  dependency-version: 0.28.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: ng-mocks
  dependency-version: 14.17.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-22 23:28:57 +02:00
4grayandClaude Opus 5 cb37f628ed fix(pwa): drop the retired demo URL from backend CORS and og:url defaults
The public demo deployment is paused for good, so the production CLIENT_URL
fallback allowed an origin nobody can reach — a manual (non-Docker) self-host
failed every provider request with a CORS error. Default to the documented
http://localhost:4333 instead, and point og:url at the project website.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:58:06 +02:00
4grayandClaude Opus 5 2d8e8d77cb fix(website): point web app CTAs at the Docker self-hosting docs
The hosted PWA at iptvnator.vercel.app is paused because the free plan's
bandwidth quota is exhausted, so both "web app" CTAs led to a dead page.
Link them to docker/README.md and reword them around self-hosting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:50:03 +02:00
4grayandClaude Fable 5 df8962969e feat(portals): make year, genre and country metadata clickable (#1449) (#1453)
* feat(portals): make year, genre and country metadata clickable (#1449)

Year, genre and country chips on movie and series detail pages now open a
Discover page inside the portal: popular TMDB titles for that facet, matched
against the user's own catalog. Generalizes the existing actor-page pattern
(TMDB list -> what's in my library -> else portal search) to metadata facets.

- All three TMDB merges emit structured `tmdb_genres`/`tmdb_countries`
  (+ `tmdb_media_type` on Stalker, whose embedded-VOD series route as movies).
  Cached details payloads already carry both, so existing rows need no refetch.
- Chips are clickable only with TMDB backing, like person chips today; the
  year chip gates on a merge-written numeric `tmdb_id`, since provider
  payloads ship junk string ids.
- `TmdbDiscoverService` fetches up to 5 `/discover` pages by popularity and
  caches them in memory only — popularity rankings are volatile and must not
  reach the persisted `tmdb_metadata` table.
- New `discover` route in both portals; containers clone the actor route,
  staleness-guarded by a facet key because facets change via query params on
  the same route instance.
- The grid, filter chips and badges move out of `ActorViewComponent` into a
  shared `TitleResultsComponent` used by both pages.

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

* refactor(portals): share the discover facet navigation across detail pages

The four render sites each carried their own copy of the year/genre/country
click handlers, which also pushed serial-details.component.ts past the
400-line limit. `createDiscoverFacetNavigation()` now owns the navigation,
the numeric-tmdb_id gate and the year parsing.

Year parsing moves from a fixed 4-char slice to the first four-digit run,
so a day-first provider date resolves instead of producing NaN.

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

* fix(portals): address review findings on the Discover pages

- The matching indicator was keyed on the request's subject, so an
  obsolete response skipped clearing it while no replacement request
  ever ran — the results grid then sat under the spinner forever.
  `createLatestRequestGuard()` now owns the indicator: the newest
  request always clears it, and the subject check keeps deciding whether
  the RESULT is still wanted. The actor pages carried the same latent
  bug and use the same guard now.

- Country chips came from `production_countries` while Discover filters
  by `with_origin_country`, so clicking a co-production partner returned
  titles originating there instead of titles it produced. Chips are now
  built from `origin_country` and labelled from `production_countries`;
  a code TMDB does not name is dropped rather than shown as a bare code.

- A cold load of an `actor` or `discover` route never initialized the
  catalog, so every result claimed to be missing from a library that
  actually holds it. Both are import-driven sections now.

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

* refactor(portals): extract the series Similar rail into its own service

Rebasing onto master pushed serial-details.component.ts back over the
400-line limit. The "Similar" rail moves into SerialDetailsSimilarService,
mirroring VodDetailsSimilarService next to it: same two sources, same
component-provided lifetime so a cross-portal lookup dies with the page.

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

* fix(portals): make facet chips keyboard-operable and reject zero years

- The chips were plain spans with a click handler, so the whole feature
  was mouse-only. Actionable chips are <button> now (focusable, Enter and
  Space activate); a year chip that cannot be discovered by stays an
  informational span rather than becoming a disabled button. The button
  chrome is neutralized so they render identically to the chips beside
  them, with a visible focus ring.

- `0000-00-00`, the placeholder providers ship for "no date", read as a
  four-digit year: the chip offered it, and the request then dropped the
  filter because 0 is falsy, so the page answered with unfiltered popular
  titles. `isTmdbYearFacet()` now gates both the chip and the route
  params, so a deep link cannot reach a state the chips refuse to offer.

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

* fix(portals): hydrate the offline catalog and hide results while matching

- `toCachedContentScope` returned null for the actor and discover routes,
  so an expired, inactive or offline portal skipped hydration entirely
  and both pages answered "not in your library" from an empty catalog
  even though a full imported catalog sat in SQLite. Both map to the
  aggregate `search` scope now — neither reads a single content type.

- The results grid stayed rendered under the matching spinner. Until the
  matches land every card reads as unavailable, so a click during the
  worker request opened the portal search for a title the next tick would
  have resolved in another playlist. The grid is hidden while matching.

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

* fix(portals): gate facet chips on enrichment, keep portal results visible

- `typeof tmdb_id === 'number'` was the wrong proof that enrichment ran:
  XtreamVodInfo.tmdb_id allows a provider-sent JSON number, so with TMDB
  disabled the year chip stayed clickable and opened a Discover page that
  cannot load anything. The target now answers the real question — can a
  facet click land anywhere — and returns null when enrichment is off, so
  the id argument is gone from the chip API entirely.

- Hiding the grid on the raw matching flag blanked valid portal results
  when the user switched back to "This portal" mid-request; a stuck worker
  would have blanked them indefinitely. The spinner belongs to the global
  scope, so it only replaces the grid while that scope is active.

- CLAUDE.md and docs/architecture/stalker-portal.md list the portal child
  routes explicitly; both now include `discover`.

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

* fix(portals): label the year chip with the year it navigates to

`facetYear()` reads the first four-digit run so a day-first provider date
resolves, but the templates still sliced the first four characters — so
`31-03-1999` rendered as `31-0` while the click opened 1999. The label
now comes from the same parser as the destination (`yearLabel`), and the
informational chip keeps its previous rendering only when no year parses.

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

* fix(portals): match Discover results by original title, guard stale loads

- `/discover` returns titles localized to the app language while the
  provider catalog stores whatever the panel named the file, usually the
  original. Discarding `original_title`/`original_name` marked owned
  titles unavailable and sent the click to a search for the wrong name.
  Results carry the alias now, and both local and cross-playlist matching
  pass it the way the recommendations rail already does.

- A facet change to B and back to A leaves two in-flight loads with the
  SAME key, so the key could not tell them apart: an older request
  failing after the newer one succeeded replaced valid results with an
  empty page. Recency decides who may commit, via the same request guard
  the matching path uses.

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

* fix(portals): wait for the catalog before stating Discover availability

Triggering initializeContent() for the discover route was only half the
fix: TMDB usually answers before a cold catalog finishes importing, and
the content gate renders the route while that runs. The page dropped its
spinner as soon as the TMDB request settled, so cards computed against an
empty catalog claimed that titles the user owns are missing and their
clicks opened a search instead of the detail page.

Availability now waits for the catalog too. Readiness is keyed on what is
in flight rather than on isContentInitialized, mirroring the recently-added
route, so a failed import settles the page instead of spinning forever.

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

* test(portals): cover the Discover catalog-readiness gate

Holds the TMDB request and the catalog flags independently so the
cold-load regression cannot return: results settling first must keep the
page loading, a finished catalog must publish them, a failed import must
still settle the page, and a running import must keep it loading.

Verified to fail on the pre-fix gate: reverting isLoading to the results
signal alone turns two of the four cases red.

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

* docs(portals): describe the Discover gates the code actually implements

Three rounds of review fixes moved the contracts out from under the
prose. The year chip no longer gates on a merge-written numeric tmdb_id
(that gate was wrong: the field is number | string, so a provider-sent
number passed it with enrichment never having run) but on the navigation
target, which requires a playlist and enabled enrichment. Discover loads
are guarded by recency, not by facet key, because A→B→A leaves two
in-flight requests sharing one key. Availability additionally waits for
catalog readiness. Both canonical entries say so now.

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

* refactor(portals): drop the normalized tmdbId that proved nothing

`NormalizedVodMeta.tmdbId` existed only to gate the year chip, and its
comment claimed a numeric `tmdb_id` proved enrichment had run. That test
was wrong — the provider field is `number | string` — so the gate moved
to the navigation target and the field lost its last consumer. Removing
it beats re-documenting it: a field that survives with a false guarantee
in its doc comment is how the rejected gate gets reintroduced.

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

* fix(portals): read the year with one rule everywhere

The shared detail path fed the chip `meta.year`, which the adapter built
with a fixed-prefix fallback: a day-first `31-03-1999` became `31-0`, so
that path both displayed the wrong label and lost the facet, since the
guard could not parse it back.

`parseFacetYear()` moves to shared/interfaces and both callers delegate
to it, so the adapters and the Discover chips cannot drift into
disagreeing about what a date says. The adapter keeps its date-parse
fallback for shapes stating no four-digit run, but no longer invents one
by slicing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:59:58 +02:00
4gray bee7df1e02 feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a
provider sent — links, Xtream credentials, a MAC address with device identity
— and a deterministic parser recognizes the source(s) and prefills the
matching import form.

- detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC
  addresses and labeled fields, classifies each finding as Xtream, Stalker or
  an M3U link/body, and returns ranked candidates. Pure and synchronous.
- Built against a corpus of 19 real reseller handouts kept verbatim in the
  spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex
  serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a
  guard so a parental PIN is never read as the account password.
- Detection only proposes: the target form's own validation and behavioral
  probes remain the sole path into the store, and no pasted text leaves the
  app. Passwords are masked on candidate cards, including query and HTTP
  Basic userinfo forms.
- Covered by parser, component and dialog unit tests plus two web E2E specs
  for the paste → pick → prefilled form workflow; i18n for all 19 languages.
2026-08-16 13:19:54 +02:00
aiddroid 03719ba51d fix(i18n): fix translation with ChatGPT 5.6 Sol (#1390) 2026-08-16 12:04:10 +02:00
4grayandClaude Fable 5 00aa623b83 fix(downloads): reconnect interrupted transfers and resume without validators (#1446)
* fix(downloads): reconnect interrupted transfers and resume without validators

Xtream panels commonly kill each VOD connection after a byte/time burst
(~130-260 MB) and send no ETag/Last-Modified. The validator-only resume
path then deleted the partial and surfaced a raw "aborted" failure, so
every Retry restarted from zero and large files could never finish.

- Resume without a validator through overlap verification: the Range
  request rewinds by 256 KiB and the replayed window must match the
  partial's tail byte-for-byte before anything is appended; a mismatch
  truncates the partial and restarts from scratch (download-overlap.ts).
- Reconnect automatically on recoverable interruptions and clean short
  responses (download-reconnect.ts): progress >=64 KiB past the best
  attempt resets a 3-stall budget; request-phase failures during
  reconnects are converted into retained interruptions so automation can
  never delete a partial.
- Extract pure response-header helpers into
  download-resume-validation.ts to keep download-transfer.ts within the
  file-size guideline.

Verified against a real throttling portal: a 1.6 GB and a 3 GB movie
completed through 15 and 23 connection resets respectively.

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

* fix(downloads): address review findings on reconnect baseline and small partials

- Judge reconnect progress against the previous attempt instead of a
  high-water mark, so a transfer that legitimately restarted from byte
  zero mid-loop (overlap mismatch, ignored Range) is measured by its
  rebuilt file; at most two such regressions are tolerated per transfer
  to keep the loop structurally bounded (Greptile P1).
- Floor reported progress at the partial's retained size while
  appending, so a response that ends inside the overlap window can never
  move persisted progress backwards (Greptile P1).
- Verify partials smaller than the overlap window in full from byte zero
  and append, instead of rewriting the .part in place — an early-dying
  reconnect can now only grow the file (Codex P2).

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

* fix(downloads): keep tolerated regressions off the reconnect stall budget

A tolerated restart regression consumed a regression credit AND counted
as a stalled attempt (its negative delta is below the progress
threshold), so a legitimately rebuilt file that grows in sub-64 KiB
steps was failed one reconnect early. Regressions are now charged to
their own bounded budget only; the stall budget stays reserved for
attempts that genuinely fail to grow the file.

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

* fix(downloads): gate success and validator promotion on complete overlap verification

Round-3 review findings:

- Success is rejected while the overlap verifier has not consumed its
  entire window: a complete 206 that ends inside the window proves the
  remote entity shrank, so the transfer truncates and restarts from
  scratch instead of finalizing the old suffix as a completed file; an
  early-dying stream stays an ordinary retained interruption (Codex P1).
- A verify-append attempt promotes the response's ETag/Last-Modified
  only after the complete overlap matched — an unverified partial is
  never blessed with a validator the next resume would If-Range-append
  onto (Codex P1).
- A tolerated restart regression resets stalls accumulated against the
  discarded representation, so the rebuilt file starts with the full
  stall budget (Greptile P1).

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

* fix(downloads): carry the known total through total-less reconnect responses

A resumed response without a usable total (chunked, or an unsatisfiable
Content-Range) erased task.totalBytes, so a reset over that response
could no longer classify as a retained interruption and generic cleanup
deleted the verified partial. The previously learned total is now
carried forward for appending attempts; fresh and restarted transfers
still drop it, since it described a discarded file.

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

* fix(downloads): never fabricate a total for an unverified retained partial

A retained failure with an unknown total persisted
totalBytes = bytesDownloaded, so after stalled reconnects over a
chunked, validator-less response that kept ending inside the overlap
window, Retry's completed-partial shortcut saw the .part size equal the
fabricated total and finalized the unverified partial without a request.
The fallback is now explicit per call site: only a finalization failure
after a complete transfer records its byte count as the total; retained
interruptions keep an unknown total unknown, forcing Retry to re-verify.

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

* fix(downloads): treat an unsatisfiable resume range as a representation change

A range-capable server whose entity shrank below the rewound overlap
offset answers 416 before any response body exists, which rejected the
request into the generic partial-deleting failure path. The 416 is now
recognized as a representation change: the partial is truncated and the
transfer restarts against the current entity from byte zero.

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

* fix(downloads): treat an indeterminate Content-Range total as unknown

`Content-Range: bytes 200-299/*` fell through to the Content-Length
fallback, deriving a "total" equal to the end of the selected range —
a resumed response ending there was declared complete and the truncated
partial finalized. An indeterminate total now yields null, letting the
previously known total carry forward and classify the short response as
a retained truncation instead.

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

* fix(downloads): signal restarts explicitly and keep carried totals informational

Round-8 review findings, both fixed at the design level instead of
another byte-comparison patch:

- Restart epochs (Greptile P1): the transfer layer now reports every
  rewrite-from-zero via task.transferRestarts (overlap mismatch, shrunk
  entity, 416, ignored Range), and the reconnect loop opens a fresh
  progress epoch on that signal — clean stall budget, no baseline. Byte
  inference could not recognize a rebuild landing near the previous
  attempt's count; the explicit signal can. Two restarts are tolerated
  per transfer; an unsignalled regression is an ordinary stall.
- Authoritative vs informational totals (Codex P1): completion and
  truncation decisions now use only the response's own total or its
  advertised indeterminate range end (`bytes X-Y/*` -> Y+1); a carried
  total is informational, is dropped once the bytes on disk falsify it,
  and can flag a short transfer but never authorize finalization. A
  mid-reset 206 retains the partial even with a falsified total — the
  response proved range capability — persisting the total as unknown.

Also raises the resume spec's jest timeout and tightens its polling:
the previous 5 s default flaked on starved CI runners.

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

* fix(downloads): keep unproven state fully uncommitted across the resume model

Round-9 review findings, closing the remaining commit-before-proof gaps:

- The response's total now stays uncommitted (task and row) until the
  complete overlap matched, exactly like the validator: a persisted
  total equal to the unverified partial's size let the completed-partial
  shortcut finalize unproven bytes after a pause, crash, or retained
  failure (Codex P1).
- Retained-interruption persistence syncs the live task with the row:
  a stale falsified total left in memory made the next reconnect's
  resume-offset guard reject the retained partial into generic cleanup
  (Codex P1).
- An observed 206 is remembered as task.serverAcceptsRanges, so a
  request-phase failure (no response at all) can retain an
  unknown-length partial on that evidence instead of deleting it
  (Codex P2).

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

* fix(downloads): treat a reset after the final ranged byte as completion

A 206 that delivers every advertised byte but ends in ECONNRESET instead
of a clean close was classified as an interruption with a falsified
total; the reconnect then resumed at EOF, collected a 416, and truncated
the complete file — an endpoint that always resets after its last byte
could never finish. A retainable failure whose on-disk bytes exactly
equal the response's own completion evidence (and the proven total, with
the overlap verified) now completes the transfer. Strict equality keeps
oversized partials on the generic-failure path.

Also splits the transfer error classes and retention classification
into download-transfer-errors.ts to stay under the max-lines rule.

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

* fix(downloads): never let an indeterminate range end authorize completion

Reaching Y of `Content-Range: bytes X-Y/*` proves the selected range
was delivered, not that the entity ends there — a range-capping server
resetting at its cap would have finalized a truncated movie as
complete. The reset-after-final-byte completion now requires the
response's authoritative total; indeterminate range ends keep flagging
short delivery but such resets stay retained interruptions.

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

* fix(downloads): retain any nonempty partial and keep indeterminate ranges incomplete

Round-12 findings, resolved by removing the last evidence requirements:

- Retention no longer needs a total, validator, or range proof: since
  overlap verification owns resume correctness, the next attempt can
  safely prove, resume, or restart over ANY retained partial — deleting
  bytes is the only unrecoverable outcome. This closes the whole family
  (refused reconnects, chunked responses, falsified totals, and
  reconstructed retry tasks losing the in-memory range flag) and removes
  task.serverAcceptsRanges entirely (Codex P2).
- A clean EOF at the advertised end of an indeterminate range
  (`bytes X-Y/*`) stays incomplete, matching the reset path: reaching Y
  proves the range was delivered, not that the entity ends there, so a
  range-capping server can no longer finalize a truncated movie; the
  shrunk-entity restart likewise requires an authoritative total
  (Codex P1). Responses with no range and no total keep the clean-EOF
  completion contract of unknown-length HTTP.

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

* fix(downloads): honor 416-confirmed completion and keep proven validators

Round-13 findings plus a CI limit:

- A 416 whose `Content-Range: bytes */N` equals the partial's size
  confirms the file IS the complete entity (under If-Range a validator
  mismatch yields 200, so the 416 also confirms identity): finalize it
  instead of truncating and redownloading forever (Codex P1).
- A validator proven by a complete overlap match is now promoted on the
  error path too, and retained-failure/pause persistence write
  resume_validator from the task — later attempts resume via If-Range
  instead of replaying the 256 KiB window, which stalled out servers
  whose per-connection cap barely exceeds the window (Codex P1).
- download-resume.spec.ts crossed the 1200-line test limit: the shared
  harness moves to download-resume.test-harness.ts and the
  overlap-family cases into download-overlap-resume.spec.ts; the runtime
  spec gets the same CI-load timeout headroom as the resume specs.

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

* fix(downloads): settle falsified totals on clean exits and probe EOF after zero-growth replays

Round-14 review findings plus the CI build break:

- download-resume.test-harness.ts was outside the tsconfig test-helpers
  exclude glob and broke every app typecheck/build; renamed to
  download-resume.test-helpers.ts (the excluded pattern).
- A clean indeterminate delivery that outgrows a stale carried total now
  settles that total to unknown on the row AND the live task before
  persisting or throwing — the reconnect's resume-offset guard would
  otherwise reject the retained partial into generic cleanup (Codex P1).
- A verified overlap replay of an indeterminate range that appends
  nothing arms a one-shot EOF probe: the next attempt requests the byte
  after the partial so a compliant 416 (bytes */N) can confirm the file
  is the complete entity, instead of repeating the rewind until the
  stall budget fails a finished download; a probe answered with more
  data is retired unappended and rewound verification resumes
  (Codex P1).

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

* fix(downloads): retain the partial on an inconclusive EOF-probe 416

A probe at the partial's exact end always collects a 416 when the
entity ends there, and the confirming Content-Range length is optional
— so a length-less 416 is equally consistent with a complete file, and
the unconditional restart redownloaded a likely finished movie every
cycle. The probe's 416 now restarts only when a stated total BELOW the
partial proves the entity shrank; otherwise the partial is retained.

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

* fix(downloads): treat any EOF-offset 416 as inconclusive, not just the probe

A validator-backed resume at the partial's exact end IS an EOF request:
when its 416 arrived without the optional Content-Range length, the
probingEof gate saw false and restartFromScratch truncated the complete
partial. The inconclusive-416 retention now keys on the request having
started at the partial's end (covering the probe and every If-Range
resume alike); a restart still requires a stated total below the
partial.

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

* fix(downloads): contradiction-proof carried totals and identity-gated 416 completion

Round-17 review findings:

- A carried total now falls the moment the response's advertised range
  end contradicts it, before any byte lands — waiting for the bytes to
  reach it left a pause/exit window where an N/N row let the
  completed-partial shortcut finalize a truncated file (Codex P1).
- The 416 completion shortcut now requires identity proof: an exact-EOF
  request backed by If-Range, or the EOF probe that follows a fully
  verified overlap replay. A bare length match on a rewound request
  proves nothing about whose bytes are on disk; a contradictory 416
  (stated total says the rewound range was satisfiable) retains instead
  of restarting (Codex P2).

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

* refactor(downloads): extract 416 classification to stay under max-lines

The previous commit pushed download-transfer.ts to 404 effective lines;
the 416 decision moves into classifyRangeNotSatisfiable() in
download-transfer-errors.ts with identical semantics, and the unused
re-export-only imports are dropped.

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

* fix(downloads): promote the proven total on the error path and fix the 416 contract doc

- The error-path promotion after a complete overlap match now updates
  task.totalBytes alongside the validator: a pause landing while the
  partial sits at a stale carried total otherwise persisted an N/N row
  that Resume's completed-partial shortcut would finalize (Codex P1).
- The download-manager contract doc's rewound-416 paragraph now matches
  classifyRangeNotSatisfiable(): completion requires identity proof at
  exact EOF, restart requires a proven shrink, everything ambiguous
  retains (Codex P1 on the doc).

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

* fix(downloads): correct both 416 classification boundaries

Round-19 review findings, one in each direction:

- A rewound 416 WITHOUT a stated length now retains: unsatisfiability
  alone never proves the entity shrank relative to the retained bytes,
  and the canonical contract reserves restart for stated proof
  (Codex P1).
- A stated total EQUAL to a rewound request's first byte now restarts:
  the entity ending exactly at the rewound offset makes the 416 valid
  and proves the partial extends past the entity — equality was being
  misread as a contradiction, stranding the download in retain forever
  (Codex P1).

classifyRangeNotSatisfiable() gains an exhaustive pure table spec
covering every branch.

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

* fix(downloads): restart on reset-ended responses that completed a shorter entity in-window

The clean-EOF path already restarted when a response delivered its
complete authoritative total inside the verification window, but a
reset arriving right after that final byte took the retention path and
stranded the oversized partial in a stall loop. The catch path now
mirrors the shrink detection: authoritative total reached inside an
unproven overlap restarts from scratch.

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

* fix(downloads): request identity encoding for byte-exact transfers

Axios's Node adapter transparently decodes gzip/brotli responses, which
would put decoded bytes on disk while Content-Length, Content-Range,
and every Range offset speak the encoded representation — desyncing
resume offsets and overlap verification on origins that compress.
Downloads now always send Accept-Encoding: identity.

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

* fix(downloads): drop carried totals an indeterminate range can exactly reach

`bytes 200-249/*` can deliver the partial exactly TO a carried total of
250; the <= guard kept that total, and a pause or exit anywhere in that
window persisted a 250/250 row the completed-partial shortcut would
finalize without EOF proof. The guard is now strict: a carried total
survives only when the advertised indeterminate range cannot reach it,
which also closes the mid-stream pause window.

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

* fix(downloads): arm the EOF probe after a reset-ended verified zero-growth replay

A verified overlap replay that reset right at the partial's end (zero
growth, indeterminate range, no validator) retained without arming the
EOF probe, so every retry replayed the same tail until the stall budget
expired — the clean-EOF path's probe arming now has its reset-path
mirror.

Also splits to stay under max-lines: the classifyRangeNotSatisfiable
table spec moves to download-transfer-errors.spec.ts and the DB persist
helpers to download-transfer-persistence.ts.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 07:07:25 +02:00
4grayandClaude Fable 5 0a2fe263db feat(portals): mark a whole series as watched in one click (#1451)
* feat(portals): mark a whole series as watched in one click

Adds a series-level watched toggle to the season header's new overflow
menu on both Xtream and Stalker series detail pages (issue #1442 v2,
building on the season-level toggle from #1447).

- Shared: buildSeriesWatchToggleRequest flattens every loaded season
  with the season builder's mark/unmark semantics; the direction is
  always the one the label advertised, never re-inferred at persist
  time. Watch-toggle state math for both scopes moves into the new
  component-provided SeasonWatchPresenter (the container component sat
  at the max-lines cap).
- Xtream: the series request reuses SerialDetailsSeasonWatchService
  through a scope-parameterized handle(), the same stillCurrent
  ownership guard, and the XtreamStore.loadAllPositions badge refresh.
- Stalker: the season handler's core is extracted into
  runWatchToggleBatch (feedback keys per scope). Lazy Ministra VOD
  hydrates unloaded seasons sequentially first (zero writes on a failed
  fetch, silent abort on navigation), re-runs the position reconcile
  synchronously so newly hydrated episodes' legacy rows are cleaned,
  then rebuilds the request keeping the clicked direction; an
  all-watched outcome reports an honest count-0 snackbar.
- Container: new hasUnloadedSeasons input blocks the fully-watched
  verdict and the count label while lazy seasons are unloaded, and the
  empty mark request contract lets the host hydrate-then-rebuild.

Six new XTREAM i18n keys, synced to all 18 locales via the i18n-fill
workflow. No new IPC: the existing playback-position batch channels are
season-agnostic.

Refs #1442

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

* fix(portals): treat an empty Stalker season answer as loaded, not pending

A lazy season the portal ANSWERS for with zero episodes was still
counted as unloaded (episodes.length === 0 heuristic): the series label
stayed countless forever and every series toggle re-fetched the empty
season, while a glitch-empty answer could silently skip a season and
still report success as if nothing remained.

VodSeriesSeasonVm gains an episodesLoaded flag set by every successful
episode fetch — including an empty one — and the series toggle's
pending predicate, hydration re-check, and hasUnloadedVodSeasons now
key on it. A loaded-and-empty season unblocks the count label and the
fully-watched verdict instead of re-fetching; a fresh detail mount
still re-fetches, so a one-off glitch self-corrects next session.

Addresses the Greptile P1 on PR #1451.

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

* fix(portals): harden lazy season hydration against malformed and racing loads

Two review findings on the series watched toggle:

- fetchVodSeriesEpisodes now trusts an empty answer only when the
  envelope actually carried a well-formed array; a malformed envelope or
  an answer whose rows contain no recognizable episode rejects, so the
  load fails instead of the season being recorded loaded-and-empty and
  silently skipped by the series batch.
- loadEpisodesForSeason is single-flight per season: a tab click, the
  spillover prefetch, the quick-start recursion, and the series-toggle
  hydration join one in-flight request instead of duplicating portal
  traffic — previously a second request's failure could abort a series
  toggle whose original request succeeded.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 01:04:12 +02:00
4grayandClaude Fable 5 7fc9380bff feat(portals): mark a full season as watched in one click (#1447)
* feat(portals): mark a full season as watched in one click

Series detail pages on both Xtream and Stalker portals get a season-level
watched toggle next to "Download season": marking writes full-progress
rows for the unwatched episodes only (real durations survive), a fully
watched season flips the action to unwatch-all.

Persistence goes through new batch IPC channels
(DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with
onConflictDoUpdate().run(); the PWA data source rewrites its
localStorage blob once). Stalker deliberately bypasses the batch IPC
and loops the existing position-mutation queue so legacy-row
reconciliation still runs and the queue coalesces to a single reload;
partial failures surface a dedicated snackbar.

Also removes the dead toggleEpisodeWatched store method, splits
season-container/serial-details-playback under the max-lines cap
(season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and
classifies *.spec-data.ts fixtures under the test max-lines ceiling
(baseline shrinks by main.preload.spec-data.ts).

Closes #1442

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

* fix(portals): guard stale season batches and split partial-unwatch feedback

Review follow-up (Codex on #1447):
- A season batch completing after the user navigated to another series
  or playlist no longer writes the old series' rows into the freshly
  reset position state (episode ids can collide across playlists); the
  Xtream host captures the playlist/series identity before awaiting and
  skips the rendered-state mutation when it changed. The DB write is
  unaffected — it carries its own playlistId.
- A partially failed "mark season as unwatched" on Stalker now reports
  a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the
  watch-direction "marked" text; translated into all 18 locales.

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

* fix(portals): exclude the playing episode from season marking and count partial saves

Second review round (Codex on #1447):
- The episode currently playing (inline or in an external session, or
  with a launch in flight) is excluded from a season's mark-watched
  batch: the player persists its live position every ~15 s and would
  immediately overwrite the just-written full-progress row. The button
  count reflects the exclusion and the action disables when nothing is
  markable. Unmarking still clears such an episode — the recreated
  in-progress row reflects live playback truthfully.
- A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row
  saved and published, only legacy cleanup failed) now counts as a
  watched success instead of feeding false total-failure feedback.

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

* fix(portals): gate stale season-batch snackbars on the originating page

Third review round (Codex on #1447): a batch resolving after the user
navigated away no longer shows its contextless success/error snackbar
on the newly opened detail page — the same ownership check that guards
the state mutation now guards the feedback too.

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

* fix(portals): sync catalog progress badges after toggles and gate Stalker feedback

Fourth review round (Codex on #1447):
- Any Xtream watched toggle (single episode or season batch) now
  refreshes XtreamStore.loadAllPositions after persisting — the catalog
  reads series-progress badges from the store, which otherwise loads
  positions once per playlist, so returning from the detail kept stale
  badges. Skipped when the playlist changed mid-flight (the store then
  belongs to the other playlist; its own init reloads positions).
- Stalker's season snackbars are gated on the captured playlist/series
  identity, matching the Xtream ownership guard — a batch draining after
  navigation no longer reports on the newly opened page.
- Stalker season-toggle specs moved to stalker-series-view.season-watch
  .spec.ts with their own harness; both prior spec files sat at the
  1200-line test ceiling.

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

* docs: describe the season watched toggle in CLAUDE.md

Fifth review round (Codex on #1447): the canonical Seasons entry in the
VOD/Series detail section now covers the bulk toggle, its playing-episode
exclusion, both persistence paths, catalog badge sync, and the
stale-completion contract.

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

* fix(portals): let only the latest positions load patch the Xtream store

Sixth review round (Codex on #1447): loadAllPositions is now
latest-load-wins — a fetch superseded while in flight (playlist switch
before getAllPlaybackPositions resolves) no longer patches the singleton
store with the previous playlist's position maps, which could leave the
new catalog showing the old playlist's progress badges.

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

* docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md

Seventh review round (Codex on #1447): both canonical max-lines
descriptions now list **/*.spec-data.ts among the test-ceiling globs so
future agents neither treat these fixtures as production files nor
remove the exemption unknowingly.

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

* fix(portals): parse "N min" durations when marking episodes watched

Eighth review round (Codex on #1447): Stalker VOD episodes report
durations like "45 min", which parseDuration could not read — bulk (and
single) mark-watched then persisted 1/1-second rows. The minute format
now parses to seconds, matching what the removed legacy store method
already handled.

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

* fix(portals): parse compound hour durations and cover the toggle end-to-end

Ninth review round (Codex on #1447):
- parseDuration now reads the compound "1h 30min" form the Xtream
  fixtures emit (hour group optional, so "45 min" keeps working) —
  bulk-marked episodes no longer persist a minutes-only duration.
- New Playwright coverage exercises the season toggle through the real
  UI on both portals: Xtream (category → series detail → mark →
  reload-persistence → unmark) and Stalker (embedded-series flow,
  mark → unmark with the item's actual episode count).

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

* fix(portals): refresh Stalker catalog progress badges after watched toggles

Tenth review round (Codex on #1447): the Stalker mirror of the Xtream
catalog sync — StalkerCatalogFacadeService loads its position maps once
per playlist and the runtime bridge only pushes external-player updates,
so renderer-initiated toggles left grid badges stale. The series view
now calls the facade's new ownership-checked refreshPositions after the
season batch (including partial successes) and after single toggles;
the reload is latest-load-wins like the Xtream store fix. Optional
injection keeps collection-detail mounts outside the catalog working.

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

* test(portals): cover the season toggle batch IPC end-to-end in Electron

Eleventh review round (Codex on #1447): the new Electron E2E marks a
season through the real UI, asserts the eight SQLite rows written by
DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge,
proves persistence with a full app relaunch (renderer and main process
die, so state can only come from the database file), and clears again
through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows.

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

* fix(dashboard): keep watched rows out of the series resume target

Twelfth review round (Codex on #1447): a watched position row — a
natural finish or a manual/bulk "mark watched" marker — is a completion
record, not resumable progress. Continue Watching no longer auto-plays
such an episode at its end; the handoff stays detail-only and the series
page's quick-start picks the first unwatched episode instead. Card
progress bars and SxxEyy badges keep their current source.

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

* fix(portals): fail closed on refresh reads and gate batch APIs by capability

Thirteenth review round (Codex on #1447):
- Position-cache refreshes now use a failure-propagating read
  (getAllPlaybackPositionsOrThrow through the Electron data source): a
  transient IPC failure rejects instead of masquerading as an empty
  list, so a populated store/facade cache stays stale-but-populated
  rather than being wiped. All load/refresh call sites handle the new
  rejection (init loads may retry on the next activation; post-toggle
  refreshes log and keep the snackbar flow).
- The season-batch bridge methods joined playbackPositionStorageMethods,
  so a bridge lacking them degrades to the in-memory path wholesale
  instead of throwing mid-action.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:03:14 +02:00
4gray 00000de798 feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448) 2026-08-15 13:59:28 +02:00
4grayandClaude Fable 5 20857e34c2 refactor(playback): extract web player diagnostic/recovery controller (#1444)
* refactor(playback): extract web player diagnostic/recovery controller

WebPlayerViewComponent sat at ~391 counted lines against the 400
max-lines cap. Move the diagnostic/recovery surface — the diagnostic
ownership/intent tokens, visible-diagnostic computed, playback-issue
handling, recommended-player switching, retry, and session sync — into
a dedicated WebPlayerRecoveryController in the same directory. The
component stays the template-facing facade with an unchanged public
API, so all four spec files pass without modification.

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

* refactor(playback): move recommendations into the recovery controller

The controller deps closed over the component's recommendations
computed, which itself read the controller's visiblePlaybackDiagnostic
— a reference cycle tsc rejects under noImplicitAny (TS7022/TS7023)
even though Jest and ESLint accept it. Owning the recommendations
computed in WebPlayerRecoveryController removes the cycle at its root
and shrinks the facade further; the component re-exposes the signal
unchanged for the template and specs.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:40:22 +02:00
4grayandClaude Opus 5 c723093890 test(playback): drive embedded MPV controller specs on a fake clock (#1434)
* test(playback): drive embedded MPV controller specs on a fake clock

The `waitFor` helper in the three embedded-mpv-session-controller specs
polled real timers against a `Date.now() + 1s` deadline. Under parallel
Jest workers the controller's async startup chain (support load ->
waitForStartupPaint -> session create -> update subscription) did not
always settle inside that budget, so a different pair of tests failed on
each run.

Bound the drain loop by rounds on a fake clock instead of elapsed wall
time, so machine load can no longer change the outcome. Each round
advances 1ms rather than 0ms: `waitForStartupPaint` nests rAF inside rAF,
and a zero-delay timer scheduled from inside a timer callback is clamped
to the next millisecond, so a 0ms advance fires the outer hop and strands
the inner one forever.

Assertions and behavior under test are unchanged. Fake timers also stop
the stalled tracker's 30s timeout leaking into the real timer queue,
which removes the "worker process failed to exit gracefully" warning.

Verified with 5x `nx test ui-playback --skip-nx-cache` and 5x the same
suite at --maxWorkers=32 (the condition that reproduced the flake):
975/975 tests passing each time.

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

* docs(playback): scope the fake-clock claim in the MPV controller spec

The comment said machine load "no longer changes the outcome", which
overclaims. Removing the spec's own `Date.now()` deadline means the spec
imposes no budget of its own, but Jest's per-test timeout still measures
wall time — at extreme worker oversubscription these specs can still hit
it, as every spec can.

Comment only; no behavior change.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:30:09 +02:00
4grayandClaude Opus 5 7952e6444d test(playback): stub the inline player in the VOD details spec (#1440)
`VodDetailsComponent` specs imported the real component tree, so every
`createComponent` pulled in `PortalInlinePlayerComponent` ->
`WebPlayerViewComponent` -> ArtPlayer, video.js and the embedded-MPV
bridge, and instantiated all of it. The suite's slowest test measured
2521ms idle on a 10-core machine -- a 2.0x margin against Jest's 5000ms
default, thin enough that a loaded worker failed tests that had no defect
behind them.

Swap the inline player for a stand-in that mirrors the selector and the
template's bindings; the specs only assert what the host hands the
player. Worst test drops 2521ms -> 306ms and the summed test time 3427ms
-> 632ms. Assertions are unchanged.

Also raise testTimeout to 15s for the shared web-ESM config. The 5s
default is thin for Angular component specs generally: TestBed compiles
and instantiates a real tree per test, and CI runners are much slower
per-core than a dev machine. This is headroom, not a mute -- a spec that
genuinely hangs still fails, just later.

Attribution, full suite at --maxWorkers=48 on 10 cores:
- forced back to 5000ms: VodDetails no longer fails (it did before this
  change); HtmlVideoPlayer still does, which is what the timeout covers
- shipped 15000ms: 2/2 runs, 975/975 passing

All six projects sharing the config pass: 1656 tests.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:28:50 +02:00
4grayandClaude Opus 5 e94cc029eb fix(portals): time out and fast-fail PWA proxy requests to dead hosts (#1424)
* refactor(portals): hoist the connectivity guard into libs/shared/host-health

The breaker was written dependency-free so both processes that talk to
portals could share it. Move the part that has no Electron in it — the
state machine, the failure classification, the redirect-attribution
helpers and the fast-fail error — into `@iptvnator/shared/host-health`
(`scope:shared` / `domain:shared-runtime` / `type:util`).

What stays in `apps/electron-backend` is the genuinely main-process part:
one guard for the whole process, so both portal IPC handlers see each
other's evidence, and the console warning that announces it. Every call
site is unchanged; the wrapper re-exports the two types they import.

The spec splits the same way — the state machine moves with the class,
the singleton and its redirect attribution stay with the wrapper.

Register the new project in the coverage policy, which every project with
a test target must declare a tier for.

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

* fix(portals): time out and fast-fail PWA proxy requests to dead hosts

The web backend's proxy routes were bare `axios.get()` calls with no
`timeout`, so a provider that accepted a connection and then went silent
held the request until the OS gave up on the TCP connection — minutes,
rather than the 15/30 s budget the Electron handlers use. Add the same
per-route timeouts (Xtream 30 s, Stalker 15 s / 30 s for `create_link`,
playlist and XMLTV 30 s).

Those numbers are safe for large downloads: on axios' default transport
`timeout` bounds the time to response headers and then continues as the
socket's inactivity timeout, so a multi-megabyte XMLTV file that keeps
delivering bytes is never cut off — only a stalled one is.

With requests bounded, run `/xtream` and `/stalker` through the shared
breaker, injected via `WebBackendAppOptions.hostGuard` so specs drive it
with a clock they own. Playlist and XMLTV downloads keep the timeout but
no breaker, matching Electron: a download is one request rather than a
catalog fan-out, it is usually the direct result of the user asking for
it, and it can outlive the half-open trial window.

The breaker is checked before the Xtream URL revalidation, which resolves
the hostname — a dead host is where DNS is slow too, and a request
admitted and then abandoned by the URL policy hands its token back rather
than holding the trial slot.

`resetHostConnectivityGuard()` no longer no-ops in the PWA: the breaker
lives in the backend process, so it travels to a new
`POST /connectivity-guard/reset`, which reads only the origin and never
logs the credential-bearing URL. `skipConnectionGuard` now survives the
PWA transport too, so Stalker endpoint discovery keeps the exemption it
has on the desktop instead of tripping the breaker with its own probes.

A fast-fail keeps each route's HTTP 200 `{message, status}` envelope. The
Stalker path needs one extra step: `forwardStalkerRequest` turns that
envelope into `HTTP Error <code>: …` with a numeric `status`, and the
renderer reads both as "the endpoint answered" — which would make
discovery walk every candidate and fire lazy repair at a host just
declared dead. A prior branch keyed on the shared
`isHostConnectivityFastFailMessage` rethrows it bare instead.

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

* fix(portals): report exempt Stalker probe responses to the PWA breaker

Flagged by the author of #1421 as one of the twelve fixes that landed
there after this branch cherry-picked the pre-review commit: an exempt
discovery probe must still REPORT, it just must not COUNT.

The web backend was skipping the report entirely for a probe, which loses
the case that matters. A failure carrying an HTTP response proves the
endpoint answered, and this route sets no `validateStatus`, so axios
rejects every non-2xx with `error.response` attached — a probe answered
with 404 or 500 was therefore dropped instead of clearing the record.
Two counted failures either side of it then read as consecutive and
opened the breaker in the middle of discovery, which is exactly what the
exemption exists to prevent.

`reportProviderRequestFailure` now takes `countFailures`, matching the
Electron reporter, and both routes always report.

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

* fix(portals): read the redirect hop from the transport that followed it

`failedAfterRedirect` decided whether a failure belonged to a redirect
destination by reading `error.config.url`. That is right for the Electron
transport, which sets `maxRedirects: 0` and reissues every hop as its own
request, so the hop IS the config URL. It is blind on the web backend,
which uses axios' default transport: follow-redirects walks the chain
inside one request and `config` is built once, so `config.url` stays the
URL we asked for.

Verified against the installed axios 1.19.0 with a live server that 302s
to a dead port:

    asked for          : http://127.0.0.1:63953/player_api.php
    config.url         : http://127.0.0.1:63953/player_api.php
    request._currentUrl: http://127.0.0.1:1/dead

So the comparison was original-vs-original, found no redirect, and
charged two dead destinations to the provider that had answered both
times with a 302 — then fast-failed it. Read `request._currentUrl` first
and fall back to `config.url`, which covers both transports; Electron's
native per-hop requests expose no `_currentUrl` and are unaffected.

Also check redirect attribution BEFORE suppressing failure counting for
an exempt probe. A 3xx from the guarded endpoint is an answer, so a probe
that observed one must clear the record; otherwise a timeout, a probe
redirected to a dead destination, and another timeout still read as two
consecutive failures.

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

* fix(portals): stop axios query params reading as a redirect

Codex found that the Xtream breaker never opened at all, and it was
right. The web backend passes credentials and the action through axios'
`params`, so axios sends `…/player_api.php?username=…&action=…` while the
baseline handed to `failedAfterRedirect` is the query-less URL the route
built. Verified against axios 1.19.0 with a plain ECONNREFUSED and no
redirect anywhere in sight:

    baseline           : http://127.0.0.1:1/player_api.php
    request._currentUrl: http://127.0.0.1:1/player_api.php?username=demo&…

The two normalized URLs differ, so every ordinary failure looked like a
post-redirect failure, credited the endpoint, and the breaker could never
trip.

Compare origin and path, not the whole URL. That keeps what the check is
for — an endpoint that answered and sent us elsewhere, including the
same-origin `/player_api.php` → `/slow/player_api.php` case — and gives
up only a redirect that changes nothing but the query, which is then
counted as an ordinary failure. Erring towards counting is the safe
direction here.

The reason 57 tests passed over a dead feature is the real lesson:
`StubHttpClient` threw bare `Error`s, so the guard's redirect check saw
neither `config.url` nor `request._currentUrl` and quietly did nothing.
The stub now shapes its rejections like axios does, including the query
axios appends. With that alone, four existing tests fail against the old
comparison.

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

* fix(portals): count a hostname that stops resolving, and fix a stale docblock

Two from review, one behavioural and one documentation.

A name that will not resolve is the host failing to answer — the same
evidence as the ENOTFOUND the transport would have raised a moment later.
But the SSRF validation turns a lookup failure into a 400 "host could not
be resolved", and the release path added earlier handed the token back as
inconclusive, so the breaker could never open for a host whose DNS died
and every request kept paying for the same dead lookup.

`ProviderUrlError` now carries the underlying lookup error internally.
A refusal that has one is counted; a genuine policy refusal — private
address, bad scheme, credentials in the URL — still only releases the
half-open slot, because that says nothing about reachability. The field
is internal: `providerUrlErrorBody()` strips it at both call sites, so
the client sees exactly the body it saw before, which the test asserts.

The docblock on `resetHostConnectivityGuard` still said the PWA channel
is unknown and the call no-ops. That stopped being true when this branch
implemented `CONNECTIVITY_GUARD_RESET` over HTTP, and a stale contract
there is how the next caller silently skips the PWA path. (The edit was
in an earlier commit and was lost when the branch was rebuilt on master.)

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

* docs(portals): record the transport-specific redirect contract

The redirect section still described one transport: hop-by-hop requests,
`error.config.url`, whole-URL comparison. Two of those three are now
wrong for the web backend, and this document is the canonical contract —
leaving it stale is how the attribution bugs fixed in the last two
commits get reintroduced.

Says what is actually true: which field holds the failed hop on each
transport and why the helper reads both, and that the comparison is
origin + path because the web backend's credentials ride in axios'
`params` and a whole-URL comparison therefore reported a redirect for
every ordinary failure.

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

* docs(portals): scope the guard summary to both processes

The opening line still defined the breaker as an Electron main-process
concern, which contradicted the ownership section below it and is the
part a reader skims to decide whether the document applies to them.

Names both processes, and records that the web backend had the worse
version of the problem first — no request timeout at all — since that is
why the timeouts and the breaker had to land there together.

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

* fix(portals): declare the shared-interfaces dependency of host-health

The new library's manifest listed only `tslib`, but its emitted JavaScript
does `require('@iptvnator/shared/interfaces')` — the guard builds its
fast-fail message with `buildHostConnectivityFastFailMessage`. Anything
resolving the built artifact from its own manifest would have failed with
MODULE_NOT_FOUND.

The manifest was copied from `shared/logging`, which imports nothing
across libraries and therefore needs nothing beyond `tslib`.
`shared/m3u-utils` is the right precedent: it imports the same library
and declares `"@iptvnator/shared/interfaces": "0.0.1"`.

Verified against the build output rather than by inspection — the emitted
`host-connectivity-guard.js` requires the module, and the generated
`dist/libs/shared/host-health/package.json` now declares it.

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

* fix(portals): bound the PWA connectivity-guard reset

The reset was a bare `fetch` with no timeout, which is the exact failure
this change exists to remove, reintroduced one layer up. Every caller
awaits the reset BEFORE issuing the request it is clearing the way for —
`retryContentInitialization` awaits it first by design — so a backend or
reverse proxy that accepts the POST and then goes quiet would leave
Retry doing nothing at all, for as long as the socket stayed open.

Bound it with an AbortController and a 5 s timer. The abort rejects,
`resetHostConnectivityGuard` swallows it as it already does for any
other failure, and the caller proceeds to its real request — which is
what "best effort" was supposed to mean. The timer is cleared in a
`finally`, and it covers the body read as well as the headers.

Five seconds because this talks to the user's own backend rather than a
provider: it should answer immediately, and a slow one must not hold up
the retry that asked for it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:27:05 +02:00
4grayandClaude Fable 5 3103eba083 fix(playback): apply saved player changes to mounted web players (#1437)
* fix(playback): apply saved player changes to mounted web players

WebPlayerViewComponent resolved the saved engine from a one-shot
StorageMap snapshot taken at mount, so a player switch from the command
palette or settings page confirmed via snackbar and persisted the
setting while an already-mounted Xtream/Stalker player silently kept
the previous engine. The same snapshot also made first play mount the
default Video.js engine and swap to the saved one once the async read
landed.

Resolve the player (and recording folder) from the live SettingsStore
signal instead and drop the snapshot entirely. Precedence is unchanged:
temporary recovery override -> host playerOverride -> saved player ->
Video.js. Hosts passing no override (Xtream/Stalker live layouts, the
portal inline detail player) now track saved changes in place; first
mount reads the already-loaded store, so the default engine no longer
flashes.

Regression coverage (all verified to fail with the fix reverted):
three unit tests on the component and two Xtream live-route e2e tests —
a palette switch reaching the mounted player without a layout remount,
and a MutationObserver engines-ever-seen assertion that the saved
engine mounts first time.

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

* fix(playback): retain mounted engine when saved player becomes MPV/VLC

With the saved player now live-tracked, a mid-session palette switch to
managed MPV/VLC cleared the inline binding on hosts without a
playerOverride and left a blank viewport — the web player view can
neither render nor launch external players. resolveRenderableWebPlayer
keeps the mounted engine in that case; the external choice applies when
the host starts the next playback. Renderable players, including
Embedded MPV, still apply live. Raised by Codex review (P2).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:57:55 +02:00
4gray 61fca6f016 fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)
An Xtream activity row is built from its `content` row, and the catalog
endpoints that create those rows carry only a title and a poster. So the
dashboard hero and the recommendations rail rebuilt their TMDB query from the
display title alone, while the detail view had searched with the original
title, the release date and often a TMDB id. Without a year
`pickConfidentMatch` requires a globally unique exact title, which common
titles never satisfy — "Inside Out" matches several films and resolves to
nothing, every time.

Three `content` columns close that gap next to the existing `backdrop_url`:
`tmdb_id`, `release_year`, `original_title`. The detail views back-fill them
from what is on screen, the activity SELECTs project them, and
`buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same
facts through its stored entry, and rows with neither keep the title-only
fallback.

Measured against a real profile before building: of 58 distinct Xtream
movie/series activity rows, 16 (28%) produce a year-less key — the cohort
where a miss is guaranteed rather than likely.

Contracts worth preserving:

- Per-column, never overwrite. Enrichment supplies the pieces at different
  times, so a row-level guard would let the first arrival block every later
  one forever.
- `release_year` is the year the PROVIDER stated. The TMDB merge fills the
  date field when the provider left it empty, so it marks its own
  substitution with `tmdb_supplied_release_date` and the extractor skips
  those — making contamination structurally impossible rather than avoided.
- The id is stored unvetted: every consumer re-gates it through
  `assessProviderId`, which re-decides per lookup where a write-time verdict
  would be permanent.
- No media-type column — for Xtream the catalog files movies and series
  apart, so `content.type` already is the media type.

Worker requests now await `getDatabase()` before dispatching. The renderer
loads before `initDatabase()` and the worker opens the database file without
running migrations, so a query issued during startup on an upgraded install
could otherwise hit a schema whose new columns do not exist yet.

Not covered: the PWA, whose catalog cache is rebuilt from the API on every
load, so a stored id would never outlive the detail view that resolved it.
2026-08-13 18:51:26 +02:00
4grayandClaude Fable 5 78587f95ba fix(portals): return from a portal handoff without losing the collection view (#1435)
* fix(portals): return from a portal handoff without losing the collection view

"View in portal" left `stalkerReturnTo` pointing at the collection URL, and
the portal detail's back affordance re-navigated there with
`navigateByUrl()`. That starts a stateless history entry, but the
collection's active tab, scope and open inline detail live only in
`window.history.state` — so back landed on the default `live` tab with the
title closed, and the portal page stayed one browser Back away.

The handoff now also sets `stalkerReturnByHistory`, and both Stalker back
handlers step back a single history entry instead. That entry is the one the
handoff itself pushed, so it restores the collection exactly as the user left
it and adds nothing to the history stack. The flag is set only by this
builder and only alongside `returnTo`, so the dashboard handoff and every
other `stalkerReturnTo` caller keeps re-navigating.

Reported by Codex on #1422 after it merged.

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

* fix(portals): scope the history-return marker to its own handoff

`openStalkerItem` is consumed on arrival, but `stalkerReturnTo` and the new
`stalkerReturnByHistory` stay on the history entry, and a Stalker detail
opens in place without pushing one. So after Back + browser Forward the same
entry can host a different title, whose back affordance would follow the
leftover marker out to the collection instead of just closing it.

The marker now carries the handed-off item's identity instead of a bare
`true`, and a marker that does not match the open title is treated as stale:
it suppresses the whole return contract, so back simply closes the detail.

Reported by Codex on #1435.

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

* refactor(portals): share the Stalker back-navigation decision

Both back handlers duplicated the marker/`returnTo` precedence verbatim, and
the catalog view kept its own copy of the identity normalization the marker
binding mirrors — two places for one rule to drift.

`resolveStalkerBackNavigation()` now owns the decision and both handlers just
apply it, while `stalkerItemIdentity()` delegates to the shared
`normalizeStalkerHandoffIdentity()`. Behaviour is unchanged; the precedence
gains direct unit coverage instead of only being exercised through the two
components.

Follow-up to Greptile's review notes on #1435.

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

* fix(portals): make the return marker one-shot and id-shape agnostic

Two defects in the marker binding, both reported by Codex on #1435:

The comparison identity read only `item.id`, but the marker is built from
`extractStalkerItemId()`, which also accepts `stream_id`/`series_id`/
`movie_id`. A collection row carrying only `movie_id` therefore compared
against an empty identity, and since a marker was present the handler
returned without stepping back or re-navigating — the back affordance simply
stopped working. It now follows the same field order.

Binding also only fixed a *different* title reopened on the entry: selecting
the original title again still matched. Honouring the marker now retires both
return keys from the entry, so a browser Forward cannot replay the handoff.

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

* fix(portals): bind the return marker to the id the detail can report

The previous attempt widened the comparison to `extractStalkerItemId()`'s
field set, but `buildStalkerSelectedVodItem()` — which every opened detail
goes through — derives `id` from `id ?? stream_id` and drops
`series_id`/`movie_id`. The wider lookup therefore ran after the lossy
normalization and still could not match, leaving the back affordance doing
nothing for those rows.

The marker is now bound to the identity the detail will actually report, and
a row whose id cannot survive normalization gets no marker at all: the
handoff falls back to re-navigating via `stalkerReturnTo`, which works.

The earlier regression tests hid this by injecting a `movie_id`-shaped
selection directly, which normalization can never produce; they now build the
selection through `buildStalkerSelectedVodItem()`.

Reported by Codex on #1435.

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

* fix(portals): retire the return contract after a native browser Back

Leaving the handed-off portal entry with the browser's own Back runs no back
affordance, so nothing consumed the marker. A Forward replay then reopened
the catalog with the contract intact, and opening the same title again
matched the identity — its Back exited to the collection instead of closing
the freshly opened detail.

`CategoryContentViewComponent` now retires the contract whenever it lands on
the entry with no handoff item and no detail open: the handoff is over, so
anything opened from the list afterwards is a fresh selection. The guard on
an open detail keeps arrival itself from retiring a contract it still needs.

Also documents, per Greptile, that a `none` decision still closes the detail
and only suppresses the navigation.

Reported by Codex on #1435.

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

* fix(portals): keep alternate-id rows on the history return

The previous revision let a row carrying only `movie_id`/`series_id` fall
back to re-navigating, which reproduced the exact defect this PR exists to
fix: the new entry has no `collectionViewState`, so the collection reopens on
its default tab with the title closed.

The builder now pins the resolved id onto the handoff state item when the raw
row carries neither `id` nor `stream_id`, so `buildStalkerSelectedVodItem()`
reports an identity the marker can bind to and those rows get the same
history return as every other one. An existing id is never overwritten.

Also scopes the arrival-side retirement to handoffs that actually set the
marker, so a plain `stalkerReturnTo` caller such as the dashboard keeps its
behaviour — I had made that unconditional, which contradicted the scope this
PR claims.

Reported by Codex on #1435.

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

* refactor(portals): drop a redundant guard in the identity normalizer

`split(':')` always yields at least one element, and the workspace does not
enable `noUncheckedIndexedAccess`, so the optional chain and `?? ''` fallback
were unreachable rather than type-required. Behaviour is unchanged.

Spotted by Greptile on #1435.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:45:50 +02:00
4grayandClaude Opus 5 0f7d529b40 refactor(portals): split the Stalker collection detail component (#1433)
StalkerCollectionDetailComponent was 529 non-blank lines, well over the
400-line production maximum, and passed lint only because it sat in
tools/eslint/max-lines-baseline.mjs — a list CLAUDE.md says must only
shrink.

Extract four cohesive units into siblings in the same folder:

- stalker-collection-playback.controller.ts — playback ownership, inline
  playback and the external-player fallback
- stalker-collection-detail-mode.ts — pure item / detail-mode / category
  resolution (movie vs regular series vs embedded VOD series vs lazy
  is_series)
- stalker-collection-favorites.controller.ts — favorites resource, sync
  and toggle
- stalker-collection-store-snapshot.ts — store snapshot capture/restore

Both controllers follow the existing StalkerVodPlaybackController model —
plain classes taking a config object, constructed in a component field
initializer — rather than DI services. The playback controller needs the
component's `item` input signal and the favorites controller's rxResource
needs an injection context; a field initializer supplies both for free,
where a DI service would have needed an extra effect to feed the input
across.

Public template bindings and the component's public API are unchanged:
the signals are re-exported by reference, so neither the template nor the
spec sees the split. The spec is untouched and its 11 tests, plus the
rest of the project suite (272), pass exactly as before.

Regenerate the max-lines baseline so the file drops off it, and update
the three doc references that named resolveDetailMode() and
resolveSelectedCategory() as methods on the component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:44:33 +02:00
4gray 365cf35317 refactor(portals): extract the destructive Xtream refresh into one flow (#1431)
The header action and the Workspace sources page each had their own ~60-line
implementation of the destructive Xtream refresh, which is why #1421 had to fix
the same connectivity-guard bug twice. `XtreamRefreshFlowService` now owns the
sequence once; the entry points supply only an `XtreamRefreshProgressReporter`,
so neither can reach the guard reset and skip it.

Extracting it surfaced a pre-existing race that neither entry point could have
fixed alone: the two guards were independent, so the same playlist could be
refreshed from both at once and the second run parked an already-emptied catalog
over the first run's snapshot, losing favorites, history, hidden categories and
playback positions. The shared flow now serializes runs per playlist, refusing a
second one before the guard reset.

Found by Greptile; the ordering is pinned by a test that fails when the check
moves below the reset.
2026-08-13 17:33:22 +02:00
4grayandClaude Fable 5 f7bb3a13db feat(playlist): open recognized M3U movies in the VOD detail view (#1420)
M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.

Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.

Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:18:06 +02:00
4gray 82a2948ffd fix(matching): keep abbreviation titles out of bare-year keys (#1426)
A leading 2-5 character uppercase token before a dash, pipe or colon was
always read as a provider tag, so a film whose NAME is such a token lost it and
normalized down to its release year alone. "AKA - 2023" became the key "2023",
where it collided with BDE, BRO, OUT, WIL and IF — and they were offered to
each other as alternative sources in VOD multi-source.

"IT - 65 (2023)" (the Italian copy of the film "65") is structurally identical,
so only the token's meaning can separate them. The leading token is now tested
against a vocabulary — TRAILING_TAG_VOCABULARY plus a prefix-only list derived
from the real catalog — but only when the strip would leave no real word
behind. A compound is read by its head, so the open-ended "4K-<lang>" family
keeps working while "INU-OH" and "PC-4L" are recognized as film names. An
unknown token keeps its title: a refused strip costs one unmatched copy, a
wrong one corrupts that film's identity in VOD multi-source, the TMDB Similar
rail, DB_MATCH_TITLES and pin keys.

"No real word" is decided by running the rest of the pipeline on the stripped
form and looking at what comes out, never by re-implementing what later stages
remove. Quality tags, trailing and underscore tags, double-dash suffixes and
season markers each otherwise smuggle the strip through, and a stage added
later is covered for free.

Validated over the live catalog, movies and series: 83 keys fixed, 0 corrupted
across 1,616,111 titles. Deriving the vocabulary from movies alone missed AMZ,
D+ and P+ and broke the Paramount+/Disney+ copies of the numeric series 1923,
1883, 24 and 9-1-1.
2026-08-13 17:01:33 +02:00
4grayandClaude Opus 5 30d76a3a46 fix(search): keep in-flight typing when the page rewrites its query params (#1432)
* fix(search): keep in-flight typing when the page rewrites its query params

`WorkspaceShellSearchSyncService` re-read `q` on every `NavigationEnd` and
unconditionally called `setSearchState(...)`, which cancels the pending
debounce and overwrites the search box. Any same-page navigation that carried
no search intent — a downloads filter chip writing `?filter=…`, a refresh bump,
or the router echoing back our own `q` — therefore ate whatever had been typed
since the last applied term.

While input is debouncing, ignore a navigation that stays on the same page and
carries the term already applied. Real search intent (route change,
back/forward, a different `q`) still syncs as before.

This is the race behind the flaky `@downloads @electron keeps global and scoped
libraries truthful …` e2e test: it clicks a filter chip and fills the searchbox
with nothing awaited in between, so under CI load the chip's navigation lands
after the keystroke, wipes the term, and `q` is never written.

Reproduced deterministically by dispatching the chip click and the `input`
event in the same page task; the searchbox value goes to `""` and `q` stays
`null`. The new spec fails on the old code for the two regression cases and
passes for the three guard cases on both.

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

* fix(search): keep history authoritative and retire the debounce on Enter

Two follow-ups from review of the same-page navigation guard.

Greptile: the guard could not tell an app-initiated `q` echo from back/forward
landing on a history entry that carries the same term. Key the exemption on
`Navigation.trigger === 'imperative'` instead, so browser history always wins
over in-flight typing. `lastSuccessfulNavigation` is set immediately before
`NavigationEnd` is emitted, so it describes the navigation being handled.

Codex: with the guard in place, an Enter commit no longer had its queued
debounce cancelled as a side effect of the resulting `NavigationEnd`. Typing
"Beta " and pressing Enter before the debounce expired applied the trimmed
term, then the stale timeout reapplied the untrimmed one — leaving the box and
URL on "Beta" while the provider store searched "Beta ". `applySearchQuery()`
now cancels the pending debounce itself, which is the correct owner of that
rule rather than relying on a navigation side effect.

Both new tests were mutation-checked: dropping either sub-fix fails exactly its
own test and no other.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:10:24 +02:00
4gray 0cba49f3e2 fix(dashboard): keep every catalog row per title key when matching (#1425) 2026-08-13 07:31:59 +02:00
4gray e3f72f7dce perf(portals): fast-fail requests to portal hosts that stopped answering (#1421) 2026-08-13 07:31:23 +02:00
4gray 2d7811eb5f feat(portals): add "View in portal" action to inline collection details (#1422) 2026-08-13 07:30:57 +02:00
4grayandClaude Opus 5 ea4214a0d8 ci(embedded-mpv): add pinned mirrors to the Linux runtime source download (#1427)
The "Build pinned Linux Embedded MPV runtime" job failed twice on 2026-08-11
because www.freedesktop.org answered GitHub runners with HTTP 418 for the
fontconfig tarball. The Linux builder curled a single pinned URL with no
fallback, so upstream rate-limiting reddened the build.

Route downloadArchive() through the shared downloadPinnedSource() helper the
macOS builder already uses, and pin a mirror for each single-host source:
fontconfig and libdisplay-info (freedesktop-hosted) plus freetype, which the
macOS builder already mirrors. Each mirror was downloaded and verified to hash
to the existing pin. The curl hardening flags and assertArchiveMatchesPin are
unchanged, and the helper verifies every candidate against the same SHA-256,
so a mirror serving different bytes is rejected rather than used.

Unlike macOS, the Linux manifest keeps sourceUrl at the canonical pinned value
even when a mirror served the bytes: notice generation and the Snap publication
boundary compare that field against the immutable pin. A used mirror is logged
instead.

build-linux-runtime.mjs now imports the downloader, so download-pinned-source.mjs
joins the released source-archive tooling set (otherwise the archive would ship
a build script it cannot run) and the Linux runtime cache key.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:57:54 +02:00
4grayandClaude Fable 5 5f4d219d3a refactor(portals): compact credential-free logging for failed Stalker requests (#1418)
* refactor(portals): log failed Stalker requests as compact credential-free summaries

Failed STALKER_REQUEST handlers dumped the entire axios error object
(config, request internals, agent state, stack) into the main-process
console — ~100 lines per dead-portal request. Extract the existing
compact Xtream error formatter into a shared portal-request-error util
(action, host, pathname, code, status, message — query string never
included) and use it from both handlers. The redundant pre-throw
"[StalkerEvents] HTTP Error" line is dropped; HTTP >=400 already
reaches the catch block and is now logged once, compactly.

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

* fix(portals): never retain an unparseable request URL in error logs

The URL guard in stalker.events.ts rejects credentialed portal URLs
before the request URL is built, so a malformed row value such as
"http://user:secret@" reaches the error formatter as-is. Its fallback
copied that raw string into `pathname`, and redactSensitiveData only
sanitizes userinfo of URLs it can PARSE — an unparseable one passes
through verbatim, password included.

Withhold the URL entirely in that branch. Verified: the added
regression test fails on the old fallback and passes with the marker.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:54:23 +02:00
4grayandClaude Fable 5 36c2867d36 feat(xtream): recognize more language tags in VOD multi-source (#1417)
* feat(xtream): recognize more language tags in VOD multi-source

The sources popover's language filter and copy chips now read prefixes
with Unicode pipe lookalikes, brackets and spaced dashes, Cyrillic tags
and MULTI. When a stream title carries no tag, the language falls back
to what the stream's visible categories unambiguously state ("EN |
Netflix") — discovery aggregates category names per (playlist, stream)
in SQL, and category prefixes must pass a known-language gate because
everyday category words like new/top/hot are real ISO 639-3 codes.

Both signals stay parsed guesses: browse filter and chips only, never
ranking, failover or dub-warning inputs.

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

* fix(xtream): address Codex review on multi-source language detection

Gate the new bracket and dash title forms through isKnownLanguageTag:
those positions carry quality/rip tags ([HD], [CAM], NEW -) whose
fabricated "language" would outrank and mask a real category-derived
one. The legacy pipe form stays permissive.

Overlay a late-arriving route category onto the existing route row in
the same-key refresh path — cold/direct routes load categories after
discovery, and the category is outside the movie key on purpose. The
mid-flight case is redelivered by the bind() effect re-running on the
controller's sources signal; that tracked read is now documented as
load-bearing and pinned by a session spec.

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

* fix(xtream): pair brackets and strip the new tag forms when matching

Greptile: the bracket prefix chose its opening and closing delimiter
independently, so a malformed "[EN)" was read as a language tag.

Codex: recognizing a prefix is only half the job — normalizeTitleKeys
has to strip the same tag, or the tagged copy never matches the bare
one and multi-source cannot offer the film at all. Its leading-tag rule
now shares the pipe-lookalike set and, on the pipe branch only, takes
the same Latin+Cyrillic any-case alphabet with no required trailing
space. Dash and colon keep their uppercase-Latin spaced form: those are
ordinary punctuation, and loosening them would amputate "ОНО: Часть 2"
the way a case-insensitive rule amputates "It: Chapter Two".

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

* fix(xtream): keep normalization uppercase-only, measured on real catalogs

The previous commit widened the pipe branch of normalizeTitleKeys to any
case and to Cyrillic, on the theory that nothing but a tag precedes a
pipe. Checked against 1.27M real catalog titles that theory is wrong in
two ways at once: "Akira | 1988" and "Coco | 2017" put the film's name
before the pipe and the year after it, and Russian catalogs write
"Момо | Momo" — localized title, then original. The widening corrupted
349 keys and rescued none, so it is reverted.

What survives is what the data supports: the pipe-lookalike set (0
changed keys, and correct for panels that use them) and dropping the
required space after a pipe (35 changed keys, genuine welded tags like
"EN|Dark Shadows" and "|FR|VO|Le dernier empereur").

A leading-tag guard that refused to strip when no letter remained is
also dropped: it fixes "AKA | 2023" but breaks "IT - 65", so telling
those apart needs a tag vocabulary and belongs in its own change.

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

* docs(xtream): cite the measured evidence for the category language gate

The gate's rationale named hypothetical category shapes. On a real
catalog the four it actually turns away are VOD (5,245 movies), KIDS
(1,010), SHOW and WWE — without it the language select offers "VOD" and
"KIDS" as languages. Comments, doc and one spec case only.

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

* docs(xtream): restore the docblock currentSourceRow lost to an insertion

routeCategoryLanguage was added between currentSourceRow's docblock and
its signature, so the paragraph describing "the row standing for the
source the route is already playing" ended up introducing a function
that returns a language string. Moved below; no behavior change.

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

* fix(xtream): stop grouping the scan tier, it can drop a matching source

content is unique per (category, type, stream), so one stream sitting in
several categories is several rows and nothing forces their titles to
agree. The GROUP BY added for group_concat let SQLite keep an arbitrary
row's title, and the normalized confirmation then rejected the whole
stream on a title a sibling row would have matched — the source vanished.

The FTS tier can afford that grouping because its window makes it
necessary; the scan tier takes no window at all, so it now returns a row
per category and their names are merged per stream in TypeScript, which
also keeps the rejected sibling's category in the language derivation.

Found by Codex. Latent rather than active on the catalog I measured (0
streams currently carry differing titles across categories), but the
schema permits it.

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

* docs(xtream): record why category names stay scoped to matched rows

Codex flagged that the FTS predicate runs before the aggregate, so a
sibling row under a localized title contributes no category. True, and
deliberate: the field is a guess feeding a chip and a browse filter, and
completing it costs measured latency — 0.74s to 2.0s for a correlated
subquery on a 3.9GB catalog, 19.7s for a second bounded lookup — to
correct a cosmetic guess in a shape that occurs 0 times in 2.7M rows.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:59:07 +02:00
4grayandClaude Fable 5 4bcd4bd390 feat(dashboard): add TMDB "Because you watched" recommendations rail (#1419)
* feat(dashboard): add TMDB "Because you watched" recommendations rail

TMDB has no account-free "for you" endpoint, so the rail seeds per-title
recommendations from up to 3 recently watched movies/series. Seeds resolve
through the enrichment facade via a shared lookup-attempt builder (extracted
from the hero service), and recommendations already ride in every cached
details payload, so watched seeds cost zero network. Per-seed lists are
interleaved round-robin, deduplicated by id and normalized title, stripped
of watched/favorited titles, and matched against imported libraries with one
batched DB_MATCH_TITLES request; only year-compatible matches render and
fewer than 5 cards hides the rail. Loads are keyed by the seed set, and a
load where no seed resolved retries instead of latching.

The header names the seed ("Because you watched X") when exactly one seed
contributed, else falls back to the generic "Recommended for you". New
dashboardRails.tmdbRecommendations toggle (default on) in Settings ->
Dashboard; 4 new i18n keys translated across all 19 locales.

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

* fix(dashboard): harden recommendations rail reload semantics

Address Codex review findings: the load latch is now keyed by the seed
set PLUS the watched/favorited exclusion set, so favoriting a recommended
title re-filters the rail instead of being ignored by the seed-only memo;
an emptied watch history clears the root-provided service's items and
seed titles instead of leaving a stale rail; and a load requested while
one is in flight is queued and re-run afterwards, so a mid-flight history
change cannot commit results for an obsolete seed set. The dashboard
effect now also tracks favorites. Three regression tests added.

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

* fix(dashboard): catalog-aware invalidation, no empty latch, original-title aliases

Address Codex round-2 findings: the load key now includes the
imported-playlist id set, so importing or deleting a playlist re-runs the
catalog matching instead of leaving dead links or hiding fresh matches; a
below-threshold (or transiently failed) match result hides the rail
WITHOUT latching, mirroring the trending rail's retry-on-empty semantics,
since matchTitles maps worker failures to an empty list; and matching plus
watched/favorited exclusion now work through both the localized TMDB title
and the original-title alias, so a catalog named in the original language
still matches while cards keep displaying the localized form. Regression
tests added for all three.

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

* fix(dashboard): reset latch on hide, alias-year fallback, language-keyed loads

Address Codex round-3 findings: hiding the rail below the match threshold
now also resets the saved load key, so returning to a previously
successful input set (un-favoriting, restoring a playlist) reloads instead
of dying on the equality guard; alias matching picks the first alias whose
match is also year-compatible, so a same-named different-year row hit by
the localized title no longer vetoes the correct original-title match; and
the load key now includes the effective TMDB language (exposed on the
enrichment facade), so switching the app language re-localizes the cards
instead of keeping the previous language all session. Regression tests
added for all three.

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

* fix(dashboard): two-tier watched-title exclusion, drop ES2019 flatMap

Address the Codex round-4 finding: a provider stores whatever the panel
named the file, so a watched "Inception 2010" never matched TMDB's
canonical "Inception" by exact key. Exclusion now runs on two tiers —
exact normalized title plus a year-gated base tier — so the year-suffixed
shape is caught while a stored "Blade Runner 2049" still cannot swallow
the 1982 film. An unknown year on either side counts as agreeing, since
re-recommending something already watched is the worse failure.

Also replaces the alias query builder's flatMap with a loop: the web app
compiles this lib against lib: es2018, where Array.prototype.flatMap does
not exist, which broke the web build and every job downstream of it.
Both exclusion tiers are pinned by mutation-verified regression tests.

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

* fix(dashboard): index recommendation exclusions the way TMDB looks them up

Address Codex round-5 findings. The watched/favorited exclusion index is
now built through the same lookup-attempt builder the seeds and the hero
use, so an activity row is indexed under the media type the detail view
enriched with rather than its routing verdict — a Stalker embedded-VOD
series routes as 'movie' but is a show to TMDB, so its recommendation
looked up series: and sailed past a movie:-only entry — and under its
stored original-language title (info.o_name), which a translated
recommendation shares no key with. Only the builder's PRIMARY attempt is
indexed: the second is a fallback guess, and indexing it would let a
watched film exclude the same-named show.

Adds Electron E2E for the new setting: the toggle now appears in the
disabled-when-dashboard-off assertion (with the trending toggle, which
was also missing), plus a restart-persistence test. Rail rendering stays
unit-covered — it needs the TMDB opt-in, live TMDB data and catalog
matches, which would make an E2E network-dependent and flaky.

All three new unit tests are mutation-verified, including one that was
passing vacuously before this round.

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

* fix(dashboard): keep every catalog row until the year gate has chosen

Address the Codex round-6 finding: buildTitleMatchIndex collapses to one
row per key before the candidate's year is known, so a catalog holding
both "Dune 1984" and "Dune 2021" keeps whichever the worker returned
first and a 2021 recommendation then fails the year check with the right
row already discarded. The rail now groups the rows per key itself and
lets the year gate pick, still preferring an exact-title match over a
year-stripped one so the shared helper's precedence is preserved.
Mutation-verified regression test.

The trending rail shares the same collapse-then-check shape and is
unaffected by this PR; flagged separately as a follow-up.

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

* fix(dashboard): survive a failed refresh, document the new rail

Address Codex round-7 findings.

A refresh that cannot reach TMDB no longer leaves the rail untouched, but
it does not blank it either: a failed request is not a verdict that there
is nothing to recommend, and removing still-valid cards is the worse
answer for an offline user. What the failure cannot excuse is a card the
user has since watched or favorited, so the retained cards are re-filtered
against the fresh exclusion index and the rail hides if too few survive.
The key stays unlatched, so the next visit still retries.

Also documents the rail in the two canonical dashboard docs I missed:
the surface diagram and render rules in docs/architecture/workspace-dashboard.md
and the rail list in the feature README. Both had also never mentioned the
sibling trending rail, so that gap is closed in the same pass.

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

* fix(dashboard): year-aware exclusions, remake-safe dedupe, key reset

Address Codex round-8 findings.

The exclusion index now records each row's release year (Stalker's
info.releasedate, else a year read off the title) with every key, and both
tiers gate on it, so a watched 1954 "Godzilla" no longer excludes the 2014
one. A row that states no year records null and keeps excluding
unconditionally, so the conservative behaviour survives where nothing is
known.

Candidate dedupe is by TMDB id only; title collisions are resolved after
matching, by the catalog row a candidate resolved to. Same-titled remakes
("Dune" 1984 and 2021) are different films and must both reach the
matcher — collapsing them beforehand let whichever arrived first fail the
year gate on behalf of the one the library actually holds — while two
candidates landing on one row would render as duplicate cards.

The offline re-filter now clears the saved load key, so restoring those
exact inputs (un-favoriting the title) rebuilds the rail instead of
hitting the equality guard.

Splits the pure helpers and data shapes into dashboard-recommendations.util.ts:
the service had crossed the 400-line production limit. All three fixes are
mutation-verified, including one test that only became real after the
mutation showed it passing on the wrong ordering.

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

* fix(dashboard): do not latch a partially resolved seed set

Address the Codex round-9 finding: when several seeds load and only some
resolve, latching marked the whole set complete, so a seed that failed
transiently lost its recommendations for the rest of the session. The load
now latches only once every seed has answered.

A seed with no TMDB match never resolves either, so that user's rail
re-runs on each dashboard visit. That is bounded work — the enrichment
misses are cached and the catalog match is one batched worker call — and
it matches the rail's existing policy of not latching on uncertainty.
Mutation-verified regression test, plus one pinning that a fully resolved
set still latches.

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

* fix(dashboard): trust only stated years on the exact exclusion tier

Address Codex round-10 findings.

The first is a regression I introduced last round: recording a
title-inferred year with the exact exclusion key meant a watched
"Blade Runner 2049" carried year 2049, disagreed with TMDB's actual 2017,
and stopped excluding the very film the user had just watched. The exact
tier now gates only on a year the row STATES in a metadata field
(Stalker's info.releasedate) — the rule releaseTagYear already documents:
on a whole-title match a trailing number belongs to the name and nothing
can settle it. The base tier keeps its stripped trailing year, which is a
suffix by construction, so the Godzilla 1954/2014 case still holds.

The offline re-filter also drops cards whose playlist has been deleted.
That path is the only one that can reach retained cards without the
catalog key rebuilding the rail, so those cards would otherwise navigate
to a dead route.

Both fixes are mutation-verified.

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

* fix(dashboard): resolved media type replaces the routing one; prefer year-tagged rows

Address Codex round-11 findings.

The exclusion index no longer indexes an activity row under BOTH its
routing type and its resolved media type. A Stalker embedded-VOD series
routes as 'movie' on positive series evidence, so keeping that key made a
watched show exclude an unrelated film of the same name — and, with no
release date to gate on, unconditionally. The resolved type now replaces
the routing one; a row the builder cannot classify keeps its routing type,
which is then the only thing known.

Catalog matching now prefers a row whose stripped year IS the candidate's
over an untagged one: an untagged "Dune" row could be either cut, so
linking a 2021 recommendation to it while "Dune 2021" also exists throws
away the better evidence. Untagged rows stay next in precedence, which is
also the only tier reachable when the candidate's year is unknown.

Both mutation-verified.

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

* fix(dashboard): no TV retry for catalog-classified Xtream rows

Address the Codex round-12 finding: the movie -> tv lookup retry exists
because a Stalker embedded-VOD series is stored as a 'movie' activity row,
but an Xtream row's type comes from a catalog that files movies and series
apart, so there 'movie' is evidence rather than a default. The retry let a
same-titled show answer for a film — the mirror of the existing rule that
a 'tv' verdict never retries as 'movie'.

The lookup item type had dropped the `source` field that distinguishes
them; restoring it is enough to gate the retry. This also tightens the
hero rail, which shares the builder. Mutation-verified.

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

* fix(dashboard): confirmed movies skip the TV retry; key by the whole attempt chain

Address Codex round-13 findings, both consequences of last round's change.

A stored Stalker `info.tmdb_id` is never a provider claim — the contract
says its only source is a match this app already gated, under that very
media type — so such a row's 'movie' verdict is no longer the ambiguous
default the TV retry exists for. Retrying it let a same-titled show answer
for a film whenever the movie lookup transiently returned null. The retry
now runs only for rows nothing has confirmed.

The lookup key is now the whole attempt sequence rather than the primary
attempt alone: two rows can share title, year and id yet differ in whether
a TV fallback follows, and callers cache by this key — the hero's
root-level memo would otherwise serve a Stalker row's TV answer as an
Xtream movie's metadata, and selectSeeds() would collapse two seeds that
do not perform the same lookup.

Both mutation-verified. One existing hero test asserted the retry for a
fixture that carries a stored id; it now pins the confirmed-identity
behaviour instead, with a separate test for the id-less retry it used to
cover.

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

* fix(dashboard): rank catalog matches by year evidence across aliases

Address the Codex round-14 finding: match selection returned as soon as
any alias had a compatible row, so an untagged row under the localized
title beat a row the original-title alias found carrying the candidate's
own year — the wrong remake when both cuts exist. Compatible rows from
every alias now form one pool ranked by evidence, with alias order kept
only as the tiebreaker inside a tier. The nested loop collapses into a
single pass in the process. Mutation-verified.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:39:43 +02:00
4grayandClaude Fable 5 dded17010d fix(playback): keep the display awake while built-in players play video (#1405)
* fix(playback): keep the display awake while built-in players play video

Closes #1095. The renderer tracks every playing <video> through
document-level capture listeners (element-level release listeners catch
the detached-element pause on component teardown) and, while any video
is playing and the document is visible, holds a display-sleep lock:
a main-process powerSaveBlocker over IPC in Electron — reliable on
Linux where Chromium's own video wake lock depends on DE D-Bus
inhibitors — and the Screen Wake Lock API in the PWA. The vote is
auto-cleared when the renderer reloads or dies. Radio's <audio>
deliberately never blocks display sleep; embedded MPV and external
MPV/VLC already manage their own inhibition.

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

* fix(playback): withdraw the keep-awake vote when the renderer crashes

A crash emits render-process-gone while the WebContents object stays
alive, so the destroyed listener alone missed it: without a follow-up
reload the display stayed pinned awake. Review finding by Codex.

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

* fix(playback): keep the display lock for picture-in-picture playback

Minimizing the window hides the document but leaves the PiP surface on
screen, so the visibility gate was releasing the lock mid-watch. A
tracked playing video that owns document.pictureInPictureElement now
counts as visible playback, and PiP enter/leave events resynchronize
the gate. Review finding by Codex.

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

* fix(playback): re-evaluate the wake lock after a rejection masked a state change

In the PWA path a hidden-visible round-trip (or pause/resume) while
wakeLock.request() was pending got swallowed by the in-flight guard; if
that request then rejected, only the flag was cleared and a continuously
playing visible video sat without a wake lock until the next unrelated
event. State changes arriving mid-flight now queue one re-evaluation on
rejection; permanent denials still don't loop because nothing queues a
retry without a fresh interleaved change. Review finding by Codex.

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

* docs(playback): mirror the display-sleep contract into AGENTS.md

AGENTS.md carries its own playback sections (radio, shared controls,
PiP), so the keep-awake contract belongs there too. Review finding by
Codex.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:23:03 +02:00
dependabot[bot]and4gray 6041233f41 chore(deps-dev): bump electron from 41.10.3 to 43.3.0 (#1414)
* chore(deps-dev): bump electron from 41.10.3 to 43.3.0

Bumps [electron](https://github.com/electron/electron) from 41.10.3 to 43.3.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v41.10.3...v43.3.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

* fix(deps): prepare Electron 43 runtime policy

---------

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 12:38:11 +02:00
dependabot[bot]and4gray 1e038657d6 chore(deps): bump better-sqlite3 from 12.9.0 to 13.0.3 (#1415)
* chore(deps): bump better-sqlite3 from 12.9.0 to 13.0.3

Bumps [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) from 12.9.0 to 13.0.3.
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.9.0...v13.0.3)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-version: 13.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* fix(deps): use better-sqlite3 prebuilt binaries

* docs(deps): note SQLite worker stability fix

---------

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 12:05:40 +02:00
dependabot[bot] 5c9411869a chore(deps): bump the npm-minor-patch group across 1 directory with 11 updates (#1416)
Bumps the npm-minor-patch group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [hls.js](https://github.com/video-dev/hls.js) | `1.6.16` | `1.6.17` |
| [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` |
| [video.js](https://github.com/videojs/video.js) | `8.23.9` | `8.24.0` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.62.0` | `1.62.1` |
| [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) | `1.15.46` | `1.15.47` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.65.0` | `8.66.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.65.0` | `8.66.0` |
| [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.65.0` | `8.66.0` |
| [ng-mocks](https://github.com/help-me-mom/ng-mocks) | `14.15.3` | `14.16.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.23.1` | `4.23.11` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.66.0` |



Updates `hls.js` from 1.6.16 to 1.6.17
- [Release notes](https://github.com/video-dev/hls.js/releases)
- [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md)
- [Commits](https://github.com/video-dev/hls.js/compare/v1.6.16...v1.6.17)

Updates `marked` from 18.0.7 to 18.0.9
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9)

Updates `video.js` from 8.23.9 to 8.24.0
- [Release notes](https://github.com/videojs/video.js/releases)
- [Changelog](https://github.com/videojs/video.js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/videojs/video.js/compare/v8.23.9...v8.24.0)

Updates `@playwright/test` from 1.62.0 to 1.62.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

Updates `@swc/core` from 1.15.46 to 1.15.47
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/commits/v1.15.47/packages/core)

Updates `@typescript-eslint/eslint-plugin` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/utils)

Updates `ng-mocks` from 14.15.3 to 14.16.1
- [Release notes](https://github.com/help-me-mom/ng-mocks/releases)
- [Changelog](https://github.com/help-me-mom/ng-mocks/blob/main/CHANGELOG.md)
- [Commits](https://github.com/help-me-mom/ng-mocks/compare/v14.15.3...v14.16.1)

Updates `tsx` from 4.23.1 to 4.23.11
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.11)

Updates `typescript-eslint` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@swc/core"
  dependency-version: 1.15.47
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: hls.js
  dependency-version: 1.6.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: ng-mocks
  dependency-version: 14.16.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: tsx
  dependency-version: 4.23.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: video.js
  dependency-version: 8.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 11:28:33 +02:00
4gray 10e8187b16 chore(deps): update epg-parser to 0.5.0 (#1413) 2026-08-11 03:29:05 +02:00
4gray de77c6d467 fix(playback): update mpegts.js to 1.8.1 (#1412) 2026-08-11 03:15:08 +02:00
4gray 77842b9d04 fix(playback): update Shaka Player to 5.2.4 (#1411) 2026-08-11 03:14:03 +02:00
4gray 861c6798ee ci(deps): split sensitive dependency updates (#1409) 2026-08-11 02:56:50 +02:00
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