mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user