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

@@ -257,6 +257,57 @@ test.describe('Electron Season Watched Toggle', () => {
timeout: 20_000,
})
.toEqual([]);
// Series scope: the header ⋮ menu routes EVERY season through
// the same batch IPC — one action, 24 full-progress rows across
// seasons 1–3 (no second restart: durability of the channel is
// already proven above).
const seriesMenuTrigger =
app.mainWindow.getByTestId('series-watch-menu');
await expect(seriesMenuTrigger).toBeVisible();
await seriesMenuTrigger.click();
const seriesToggle = app.mainWindow.getByTestId(
'toggle-series-watched'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText(
`Mark series as watched (${seasonCount * seasonEpisodeCount})`
);
await seriesToggle.click();
await expect(
app.mainWindow.locator('.season-tabs__done')
).toHaveCount(seasonCount, { timeout: 15_000 });
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toHaveLength(seasonCount * seasonEpisodeCount);
const seriesRows = await readEpisodePositions(
app.mainWindow,
playlistId
);
expect(
new Set(seriesRows.map((row) => row.seasonNumber))
).toEqual(new Set([1, 2, 3]));
expect(
seriesRows.every((row) => row.positionSeconds > 0)
).toBe(true);
// Unwatch-all clears all 24 rows again through the clear batch.
await seriesMenuTrigger.click();
await expect(seriesToggle).toContainText(
'Mark series as unwatched'
);
await seriesToggle.click();
await expect(
app.mainWindow.locator('.season-tabs__done')
).toHaveCount(0, { timeout: 15_000 });
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toEqual([]);
} finally {
await closeElectronApp(app);
}