mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user