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>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-16 01:04:12 +02:00
1 parent 7fc9380bff
commit 0a2fe263db
48 files changed
+2340 -261

No files matched your search

+39 -2
View File
@@ -280,8 +280,45 @@ same captured playlist/series identity. After any Xtream toggle
(single or batch) the host refreshes `XtreamStore.loadAllPositions` —
the catalog reads series-progress badges from the store, which otherwise
loads positions once per playlist — unless the playlist changed
meanwhile. The
host reports busy-state back through the `seasonWatchBatchRunning` input.
meanwhile. The host reports busy-state back through the
`seasonWatchBatchRunning` input.
A series-level counterpart lives in a `⋮` menu at the end of the same
header row (`SeasonWatchPresenter` in `libs/ui/components` owns the state
math for both scopes; the container component sits at the max-lines cap).
`buildSeriesWatchToggleRequest` flattens every LOADED season with the same
mark/unmark semantics, and the direction is always the one the label
advertised (`markWatched: !seriesFullyWatched()`), never re-inferred from
data at persist time. Hosts route the request through the same machinery
as the season toggle — Xtream via the scope-parameterized
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via the
extracted `runWatchToggleBatch` core — sharing the busy flag, the
ownership guards, and the catalog-badge refresh. Stalker lazy-VOD is the
special case: unopened seasons have empty episode lists, so the container
reports them through the `hasUnloadedSeasons` input (blocks the
"fully watched" verdict and switches the label to its countless variant),
and may emit an EMPTY mark request when every loaded episode is watched.
The Stalker host then hydrates the missing seasons sequentially through
`loadEpisodesForSeason` (aborting silently on navigation, and aborting
with zero writes plus the series failure snackbar if any season fails to
load). A season the portal ANSWERS for with zero episodes becomes
loaded-and-empty (`VodSeriesSeasonVm.episodesLoaded`), not pending —
`episodes.length === 0` alone would keep it counted as unloaded forever,
locking the label countless and re-fetching it on every series toggle.
Only a well-formed empty array earns that trust: `fetchVodSeriesEpisodes`
rejects a malformed envelope or an answer whose rows contain no
recognizable episode, so those fail the load instead of masquerading as
an empty season. `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 it (a second request's failure could abort a toggle whose
original request succeeded).
The host synchronously re-runs the position reconcile
(`applyReconciledSeriesPositions` — the effect-fed maps only update on
the next change-detection tick, and enqueuing against stale maps would
miss the hydrated episodes' legacy rows), rebuilds the request from the
now-complete seasons keeping the captured direction, and reports an
honest count-0 success if hydration reveals nothing left to mark.
## Components