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

+68
View File
@@ -1024,6 +1024,74 @@ test('@stalker season watched toggle — embedded series marks and clears every
await expect(watchedCards).toHaveCount(0);
});
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
page,
request,
}) => {
// Same modeled embedded-series flow as the season test above, driven
// through the series-level ⋮ menu instead of the season button.
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
await addStalkerPortal(page, {
name: 'Embedded Series Watch Menu Portal',
mac: EMBEDDED_SERIES_MAC,
});
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
// The ⋮ trigger sits after the view toggle (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the menu item
// renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible();
await menuTrigger.click();
const seriesToggle = page.locator(
'[data-test-id="toggle-series-watched"]'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
await seriesToggle.click();
// Every episode of the embedded season flips to watched and the action
// becomes unwatch-all.
const watchedCards = page.locator('.episode-card--watched');
await expect(watchedCards).toHaveCount(episodeCount, {
timeout: 15_000,
});
await menuTrigger.click();
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
await menuTrigger.click();
await expect(seriesToggle).toContainText(
`Mark series as watched (${episodeCount})`
);
});
test('@stalker series — seasons load for a series item', async ({
request,
}) => {
+89
View File
@@ -772,6 +772,95 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// Series-level watched toggle: the season header's ⋮ menu marks EVERY season
// in one action (default scenario: 3 seasons × 8 episodes = 24), flips to
// unwatch-all once the whole series is watched, and survives a reload.
// ---------------------------------------------------------------------------
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
page,
request,
}) => {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// The ⋮ series menu sits at the end of the season-header actions row
// (data-test-id with a dash — getByTestId only matches data-testid in
// this suite; the menu item renders into the CDK overlay).
const menuTrigger = page.locator('[data-test-id="series-watch-menu"]');
await expect(menuTrigger).toBeVisible({ timeout: 15_000 });
const seasonTabs = page.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(3);
await menuTrigger.click();
const seriesToggle = page.locator(
'[data-test-id="toggle-series-watched"]'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText('Mark series as watched (24)');
await seriesToggle.click();
// One batch marks all 24 episodes: every season tab gets the completed
// check and the visible season's episodes flip to watched.
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(8);
// The action now offers unwatch-all.
await menuTrigger.click();
await expect(seriesToggle).toContainText('Mark series as unwatched');
await page.keyboard.press('Escape');
// PWA persistence: positions live in localStorage, so a reload must come
// back fully watched across all seasons.
await page.reload();
await expect(menuTrigger).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 10_000,
});
// Unwatch-all clears every season again.
await menuTrigger.click();
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(0);
await menuTrigger.click();
await expect(seriesToggle).toContainText('Mark series as watched (24)');
});
type XtreamLiveStream = {
category_id: string;
name: string;