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
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: feature
|
||||
area: portals
|
||||
issues: [1442]
|
||||
---
|
||||
|
||||
The series pages' season header now has a ⋮ menu that marks the WHOLE series
|
||||
as watched (or unwatched) in one click, for both Xtream and Stalker portals.
|
||||
On Ministra portals that load seasons lazily, the app first fetches the
|
||||
remaining seasons and then marks everything — so catching up on a show you
|
||||
started mid-way is a single action.
|
||||
@@ -1306,7 +1306,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
|
||||
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. Contract: `docs/architecture/embedded-inline-playback.md`
|
||||
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
|
||||
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
|
||||
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "تم وضع علامة على {{count}} · فشل {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "تمت إزالة العلامة عن {{count}} · فشل {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم",
|
||||
"MARK_SERIES_WATCHED": "وضع علامة على المسلسل كمشاهد ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "وضع علامة على المسلسل كمشاهد",
|
||||
"MARK_SERIES_UNWATCHED": "وضع علامة على المسلسل كغير مشاهد",
|
||||
"SERIES_MARKED_UNWATCHED": "تم وضع علامة على المسلسل كغير مشاهد",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "فشل تحديث المسلسل",
|
||||
"SERIES_WATCH_MENU": "إجراءات مشاهدة المسلسل",
|
||||
"EPISODES_WATCHED": "تمت مشاهدة {{count}} من {{total}} حلقة",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "البيانات الوصفية للبوابة غير متاحة",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} تعلمو · {{failed}} فشلو",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} تعلمو كما متشافينش · {{failed}} فشلو",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم",
|
||||
"MARK_SERIES_WATCHED": "علم السلسلة كمتشافة ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "علم السلسلة كمتشافة",
|
||||
"MARK_SERIES_UNWATCHED": "علم السلسلة كما متشافاش",
|
||||
"SERIES_MARKED_UNWATCHED": "السلسلة تعلمات كما متشافاش",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "فشل تحديث السلسلة",
|
||||
"SERIES_WATCH_MENU": "إجراءات مشاهدة السلسلة",
|
||||
"EPISODES_WATCHED": "{{count}} من {{total}} ديال الحلقات متشافين",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "البيانات الوصفية ديال البوابة ما متوفراش",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} адзначана · {{failed}} не ўдалося",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} знята · {{failed}} не ўдалося",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць сезон",
|
||||
"MARK_SERIES_WATCHED": "Адзначыць серыял як прагледжаны ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Адзначыць серыял як прагледжаны",
|
||||
"MARK_SERIES_UNWATCHED": "Адзначыць серыял як непрагледжаны",
|
||||
"SERIES_MARKED_UNWATCHED": "Серыял адзначаны як непрагледжаны",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць серыял",
|
||||
"SERIES_WATCH_MENU": "Дзеянні прагляду серыяла",
|
||||
"EPISODES_WATCHED": "{{count}} з {{total}} эпізодаў прагледжана",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Метаданыя партала недаступныя",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} markiert · {{failed}} fehlgeschlagen",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} als nicht angesehen markiert · {{failed}} fehlgeschlagen",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Aktualisierung der Staffel fehlgeschlagen",
|
||||
"MARK_SERIES_WATCHED": "Serie als angesehen markieren ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Serie als angesehen markieren",
|
||||
"MARK_SERIES_UNWATCHED": "Serie als nicht angesehen markieren",
|
||||
"SERIES_MARKED_UNWATCHED": "Serie als nicht angesehen markiert",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Aktualisierung der Serie fehlgeschlagen",
|
||||
"SERIES_WATCH_MENU": "Aktionen zum Markieren der Serie",
|
||||
"EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal-Metadaten fehlen",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Σημειώθηκαν {{count}} · Απέτυχαν {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Αφαιρέθηκε η σήμανση από {{count}} · Απέτυχαν {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Η ενημέρωση της σεζόν απέτυχε",
|
||||
"MARK_SERIES_WATCHED": "Σήμανση σειράς ως προβληθείσα ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Σήμανση σειράς ως προβληθείσα",
|
||||
"MARK_SERIES_UNWATCHED": "Σήμανση σειράς ως μη προβληθείσα",
|
||||
"SERIES_MARKED_UNWATCHED": "Η σειρά σημειώθηκε ως μη προβληθείσα",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Η ενημέρωση της σειράς απέτυχε",
|
||||
"SERIES_WATCH_MENU": "Ενέργειες παρακολούθησης σειράς",
|
||||
"EPISODES_WATCHED": "{{count}} από {{total}} επεισόδια προβλήθηκαν",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marked · {{failed}} failed",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} unmarked · {{failed}} failed",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Updating the season failed",
|
||||
"MARK_SERIES_WATCHED": "Mark series as watched ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Mark series as watched",
|
||||
"MARK_SERIES_UNWATCHED": "Mark series as unwatched",
|
||||
"SERIES_MARKED_UNWATCHED": "Series marked as unwatched",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Updating the series failed",
|
||||
"SERIES_WATCH_MENU": "Series watch actions",
|
||||
"EPISODES_WATCHED": "{{count}} of {{total}} episodes watched",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal metadata unavailable",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marcados · {{failed}} fallidos",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} desmarcados · {{failed}} fallidos",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "La actualización de la temporada falló",
|
||||
"MARK_SERIES_WATCHED": "Marcar serie como vista ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Marcar serie como vista",
|
||||
"MARK_SERIES_UNWATCHED": "Marcar serie como no vista",
|
||||
"SERIES_MARKED_UNWATCHED": "Serie marcada como no vista",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "La actualización de la serie falló",
|
||||
"SERIES_WATCH_MENU": "Acciones de visualización de la serie",
|
||||
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadatos del portal no disponibles",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marqués · {{failed}} échoués",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} marqués comme non vus · {{failed}} échoués",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Échec de la mise à jour de la saison",
|
||||
"MARK_SERIES_WATCHED": "Marquer la série comme vue ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Marquer la série comme vue",
|
||||
"MARK_SERIES_UNWATCHED": "Marquer la série comme non vue",
|
||||
"SERIES_MARKED_UNWATCHED": "Série marquée comme non vue",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Échec de la mise à jour de la série",
|
||||
"SERIES_WATCH_MENU": "Actions de visionnage de la série",
|
||||
"EPISODES_WATCHED": "{{count}} sur {{total}} épisodes vus",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Métadonnées du portail indisponibles",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} megjelölve · {{failed}} sikertelen",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} jelölés törölve · {{failed}} sikertelen",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Nem sikerült frissíteni az évadot",
|
||||
"MARK_SERIES_WATCHED": "Sorozat megjelölése megtekintettként ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Sorozat megjelölése megtekintettként",
|
||||
"MARK_SERIES_UNWATCHED": "Sorozat megjelölése nem megtekintettként",
|
||||
"SERIES_MARKED_UNWATCHED": "Sorozat megjelölve nem megtekintettként",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Nem sikerült frissíteni a sorozatot",
|
||||
"SERIES_WATCH_MENU": "A sorozat megtekintési műveletei",
|
||||
"EPISODES_WATCHED": "Megtekintett epizódok: {{count}} / {{total}}",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "A portál metaadatai nem állnak rendelkezésre",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} segnati · {{failed}} non riusciti",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} segnati come non visti · {{failed}} non riusciti",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Aggiornamento della stagione non riuscito",
|
||||
"MARK_SERIES_WATCHED": "Segna la serie come vista ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Segna la serie come vista",
|
||||
"MARK_SERIES_UNWATCHED": "Segna la serie come non vista",
|
||||
"SERIES_MARKED_UNWATCHED": "Serie segnata come non vista",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Aggiornamento della serie non riuscito",
|
||||
"SERIES_WATCH_MENU": "Azioni di visione della serie",
|
||||
"EPISODES_WATCHED": "{{count}} di {{total}} episodi visti",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadati del portale non disponibili",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "視聴済み {{count}} · 失敗 {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "未視聴 {{count}} · 失敗 {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "シーズンの更新に失敗しました",
|
||||
"MARK_SERIES_WATCHED": "シリーズを視聴済みにする({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "シリーズを視聴済みにする",
|
||||
"MARK_SERIES_UNWATCHED": "シリーズを未視聴にする",
|
||||
"SERIES_MARKED_UNWATCHED": "シリーズを未視聴にしました",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "シリーズの更新に失敗しました",
|
||||
"SERIES_WATCH_MENU": "シリーズの視聴操作",
|
||||
"EPISODES_WATCHED": "{{count}} / {{total}}エピソード視聴済み",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "ポータルのメタデータは利用できません",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}}개 표시됨 · {{failed}}개 실패",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}}개 표시 해제됨 · {{failed}}개 실패",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "시즌 업데이트에 실패했습니다",
|
||||
"MARK_SERIES_WATCHED": "시리즈를 시청한 것으로 표시 ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "시리즈를 시청한 것으로 표시",
|
||||
"MARK_SERIES_UNWATCHED": "시리즈를 시청하지 않은 것으로 표시",
|
||||
"SERIES_MARKED_UNWATCHED": "시리즈가 시청하지 않은 것으로 표시되었습니다",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "시리즈 업데이트에 실패했습니다",
|
||||
"SERIES_WATCH_MENU": "시리즈 시청 작업",
|
||||
"EPISODES_WATCHED": "{{total}}개 중 {{count}}개 에피소드 시청",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "포털 메타데이터를 사용할 수 없습니다",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} gemarkeerd · {{failed}} mislukt",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} als niet bekeken gemarkeerd · {{failed}} mislukt",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Bijwerken van het seizoen is mislukt",
|
||||
"MARK_SERIES_WATCHED": "Serie markeren als bekeken ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Serie markeren als bekeken",
|
||||
"MARK_SERIES_UNWATCHED": "Serie markeren als niet bekeken",
|
||||
"SERIES_MARKED_UNWATCHED": "Serie gemarkeerd als niet bekeken",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Bijwerken van de serie is mislukt",
|
||||
"SERIES_WATCH_MENU": "Kijkacties voor serie",
|
||||
"EPISODES_WATCHED": "{{count}} van {{total}} afleveringen bekeken",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portaalmetadata niet beschikbaar",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Oznaczono {{count}} · Nieudane {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Odznaczono {{count}} · Nieudane {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Nie udało się zaktualizować sezonu",
|
||||
"MARK_SERIES_WATCHED": "Oznacz serial jako obejrzany ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Oznacz serial jako obejrzany",
|
||||
"MARK_SERIES_UNWATCHED": "Oznacz serial jako nieobejrzany",
|
||||
"SERIES_MARKED_UNWATCHED": "Oznaczono serial jako nieobejrzany",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Nie udało się zaktualizować serialu",
|
||||
"SERIES_WATCH_MENU": "Akcje oglądania serialu",
|
||||
"EPISODES_WATCHED": "Obejrzano {{count}} z {{total}} odcinków",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadane portalu niedostępne",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marcados · {{failed}} com falha",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} desmarcados · {{failed}} com falha",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "A atualização da temporada falhou",
|
||||
"MARK_SERIES_WATCHED": "Marcar série como assistida ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Marcar série como assistida",
|
||||
"MARK_SERIES_UNWATCHED": "Marcar série como não assistida",
|
||||
"SERIES_MARKED_UNWATCHED": "Série marcada como não assistida",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "A atualização da série falhou",
|
||||
"SERIES_WATCH_MENU": "Ações de visualização da série",
|
||||
"EPISODES_WATCHED": "{{count}} de {{total}} episódios assistidos",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Metadados do portal indisponíveis",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "Отмечено: {{count}} · Ошибок: {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "Снято отметок: {{count}} · Ошибок: {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Не удалось обновить сезон",
|
||||
"MARK_SERIES_WATCHED": "Отметить сериал как просмотренный ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Отметить сериал как просмотренный",
|
||||
"MARK_SERIES_UNWATCHED": "Отметить сериал как непросмотренный",
|
||||
"SERIES_MARKED_UNWATCHED": "Сериал отмечен как непросмотренный",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Не удалось обновить сериал",
|
||||
"SERIES_WATCH_MENU": "Действия просмотра сериала",
|
||||
"EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Метаданные портала недоступны",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} işaretlendi · {{failed}} başarısız",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} işaret kaldırıldı · {{failed}} başarısız",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "Sezon güncellemesi başarısız oldu",
|
||||
"MARK_SERIES_WATCHED": "Diziyi İzlendi Olarak İşaretle ({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "Diziyi İzlendi Olarak İşaretle",
|
||||
"MARK_SERIES_UNWATCHED": "Diziyi İzlenmedi Olarak İşaretle",
|
||||
"SERIES_MARKED_UNWATCHED": "Dizi izlenmedi olarak işaretlendi",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "Dizi güncellemesi başarısız oldu",
|
||||
"SERIES_WATCH_MENU": "Dizi izleme işlemleri",
|
||||
"EPISODES_WATCHED": "{{total}} bölümden {{count}} tanesi izlendi",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "Portal üst verisi mevcut değil",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "已标记 {{count}} · 失败 {{failed}}",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "已取消标记 {{count}} · 失败 {{failed}}",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "无法更新本季",
|
||||
"MARK_SERIES_WATCHED": "标记整部剧集为已观看({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "标记整部剧集为已观看",
|
||||
"MARK_SERIES_UNWATCHED": "标记整部剧集为未观看",
|
||||
"SERIES_MARKED_UNWATCHED": "剧集已标记为未观看",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "无法更新剧集",
|
||||
"SERIES_WATCH_MENU": "剧集观看操作",
|
||||
"EPISODES_WATCHED": "已观看 {{count}} / {{total}} 集",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "门户元数据不可用",
|
||||
|
||||
@@ -900,6 +900,12 @@
|
||||
"SEASON_MARKED_WATCHED_PARTIAL": "{{count}} 集已標記 · {{failed}} 集失敗",
|
||||
"SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} 集已取消標記 · {{failed}} 集失敗",
|
||||
"SEASON_WATCH_UPDATE_FAILED": "本季更新失敗",
|
||||
"MARK_SERIES_WATCHED": "標記整部影集為已觀看({{count}})",
|
||||
"MARK_SERIES_WATCHED_ALL": "標記整部影集為已觀看",
|
||||
"MARK_SERIES_UNWATCHED": "標記整部影集為未觀看",
|
||||
"SERIES_MARKED_UNWATCHED": "本影集已標記為未觀看",
|
||||
"SERIES_WATCH_UPDATE_FAILED": "本影集更新失敗",
|
||||
"SERIES_WATCH_MENU": "影集觀看操作",
|
||||
"EPISODES_WATCHED": "已觀看 {{total}} 集中的 {{count}} 集",
|
||||
"DETAIL_FALLBACK": {
|
||||
"STATUS": "無法取得入口網站中繼資料",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface VodSeriesSeasonVm {
|
||||
episodes: StalkerVodSeriesEpisode[];
|
||||
isLoading: boolean;
|
||||
isExpanded: boolean;
|
||||
/**
|
||||
* True once the portal has answered an episode fetch for this season —
|
||||
* including an EMPTY answer. Distinguishes "loaded and empty per portal"
|
||||
* from "not fetched yet": `episodes.length === 0` alone conflates them,
|
||||
* which would keep an empty season permanently counted as unloaded.
|
||||
*/
|
||||
episodesLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerSeriesSeasonVm {
|
||||
|
||||
+52
@@ -246,4 +246,56 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
|
||||
|
||||
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Only a well-formed empty array is a trusted empty season: the series
|
||||
// watched toggle marks an answered-empty season as loaded, so a
|
||||
// malformed envelope collapsing to [] would silently skip that season.
|
||||
describe('fetchVodSeriesEpisodes strictness', () => {
|
||||
it('trusts a well-formed empty answer in either envelope shape', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
|
||||
await expect(
|
||||
store.fetchVodSeriesEpisodes('11', 'season-2')
|
||||
).resolves.toEqual([]);
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: [] });
|
||||
await expect(
|
||||
store.fetchVodSeriesEpisodes('11', 'season-2')
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a malformed envelope instead of reporting an empty season', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: {} });
|
||||
await expect(
|
||||
store.fetchVodSeriesEpisodes('11', 'season-2')
|
||||
).rejects.toThrow('Malformed Stalker season episodes response');
|
||||
});
|
||||
|
||||
it('rejects an answer whose rows contain no recognizable episode', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { data: [{ id: 'junk-row', is_season: true }] },
|
||||
});
|
||||
await expect(
|
||||
store.fetchVodSeriesEpisodes('11', 'season-2')
|
||||
).rejects.toThrow(
|
||||
'Stalker season answered without recognizable episodes'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns sorted episodes for a proper answer', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
data: [
|
||||
{ id: 'e2', is_episode: true, series_number: 2 },
|
||||
{ id: 'e1', is_episode: true, series_number: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
store.fetchVodSeriesEpisodes('11', 'season-2')
|
||||
).resolves.toEqual([
|
||||
{ id: 'e1', is_episode: true, series_number: 1 },
|
||||
{ id: 'e2', is_episode: true, series_number: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+29
-3
@@ -53,7 +53,15 @@ interface StalkerSeriesResponse<T> {
|
||||
|
||||
type StalkerSeriesStoreContext = StalkerSeriesFeatureStoreContract;
|
||||
|
||||
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
|
||||
/**
|
||||
* Strict variant: null when the envelope carried no array at all, so a
|
||||
* malformed answer is distinguishable from a well-formed empty list. The
|
||||
* series watched toggle treats an answered-empty season as loaded, so a
|
||||
* malformed envelope collapsing to [] would silently skip that season.
|
||||
*/
|
||||
function extractSeriesItemsStrict<T>(
|
||||
response: StalkerSeriesResponse<T>
|
||||
): T[] | null {
|
||||
if (Array.isArray(response?.js)) {
|
||||
return response.js;
|
||||
}
|
||||
@@ -62,7 +70,11 @@ function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
|
||||
return response.js.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSeriesItems<T>(response: StalkerSeriesResponse<T>): T[] {
|
||||
return extractSeriesItemsStrict(response) ?? [];
|
||||
}
|
||||
|
||||
function toMovieId(value: unknown): string {
|
||||
@@ -257,7 +269,16 @@ export function withStalkerSeries() {
|
||||
p: '1',
|
||||
});
|
||||
|
||||
const episodeItems = extractSeriesItems(response);
|
||||
// Only a well-formed empty array is a trusted empty
|
||||
// season. A malformed envelope, or rows in which no
|
||||
// episode is recognizable, rejects so callers treat
|
||||
// the load as failed instead of loaded-and-empty.
|
||||
const episodeItems = extractSeriesItemsStrict(response);
|
||||
if (episodeItems === null) {
|
||||
throw new Error(
|
||||
'Malformed Stalker season episodes response'
|
||||
);
|
||||
}
|
||||
if (episodeItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -267,6 +288,11 @@ export function withStalkerSeries() {
|
||||
(item) => item.is_episode === true
|
||||
)
|
||||
);
|
||||
if (episodes.length === 0) {
|
||||
throw new Error(
|
||||
'Stalker season answered without recognizable episodes'
|
||||
);
|
||||
}
|
||||
|
||||
patchState(store, {
|
||||
vodSeriesEpisodes: episodes,
|
||||
|
||||
+4
@@ -210,6 +210,7 @@
|
||||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
|
||||
"
|
||||
[seasonWatchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[hasUnloadedSeasons]="hasUnloadedVodSeasons()"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(playbackToggleRequested)="
|
||||
@@ -218,6 +219,9 @@
|
||||
(seasonPlaybackToggleRequested)="
|
||||
handleSeasonPlaybackToggleRequestedFromUi($event)
|
||||
"
|
||||
(seriesPlaybackToggleRequested)="
|
||||
handleSeriesPlaybackToggleRequestedFromUi($event)
|
||||
"
|
||||
#seasonContainer
|
||||
/>
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,11 +25,11 @@ import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
standalone: true,
|
||||
template: '<div data-testid="season-container"></div>',
|
||||
})
|
||||
class StubSeasonContainerComponent {
|
||||
readonly seasons = input<unknown>(null);
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
readonly seriesId = input<number | string | null>(null);
|
||||
readonly playlistId = input('');
|
||||
readonly seriesTitle = input<string | undefined>(undefined);
|
||||
|
||||
+342
-106
@@ -25,6 +25,8 @@ import {
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
SeasonContainerSeriesPlaybackToggleRequest,
|
||||
buildSeriesWatchToggleRequest,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
pickSeasonMarkedTitle,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import {
|
||||
isLiveExternalPlayerSession,
|
||||
isPortalPlaybackWatched,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
@@ -107,6 +110,33 @@ interface SeriesPositionContext {
|
||||
readonly mutationKey: string;
|
||||
}
|
||||
|
||||
interface StalkerWatchToggleFeedback {
|
||||
readonly marked: string;
|
||||
readonly unmarked: string;
|
||||
readonly partialMarked: string;
|
||||
readonly partialUnmarked: string;
|
||||
readonly failed: string;
|
||||
}
|
||||
|
||||
// The marked and partial keys are scope-generic on purpose ("{{count}}
|
||||
// episodes marked as watched", "{{count}} marked · {{failed}} failed");
|
||||
// only unmark-success and failure name their scope.
|
||||
const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
|
||||
marked: 'XTREAM.SEASON_MARKED_WATCHED',
|
||||
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
|
||||
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
|
||||
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
|
||||
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
|
||||
};
|
||||
|
||||
const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
|
||||
marked: 'XTREAM.SEASON_MARKED_WATCHED',
|
||||
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
|
||||
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
|
||||
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
|
||||
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
|
||||
};
|
||||
|
||||
interface StalkerSeriesPlaybackRequestContext {
|
||||
readonly generation: number;
|
||||
readonly usesEmbeddedPlayer: boolean;
|
||||
@@ -389,41 +419,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
|
||||
const rawSeriesPositions = this.rawSeriesPositions();
|
||||
const episodesBySeason = this.mappedSeasons();
|
||||
|
||||
if (!item || !playlistId || seriesXtreamId <= 0) {
|
||||
if (rawSeriesPositions.length > 0) {
|
||||
this.rawSeriesPositions.set([]);
|
||||
}
|
||||
if (this.episodePlaybackPositions().size > 0) {
|
||||
this.episodePlaybackPositions.set(new Map());
|
||||
}
|
||||
if (this.legacyPositionByTrackingId().size > 0) {
|
||||
this.legacyPositionByTrackingId.set(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciled = reconcileStalkerSeriesPositions({
|
||||
seriesXtreamId,
|
||||
episodesBySeason,
|
||||
seriesPositions: rawSeriesPositions,
|
||||
});
|
||||
if (
|
||||
rawSeriesPositions.length === 0 &&
|
||||
reconciled.positionsByTrackingId.size === 0 &&
|
||||
untracked(() => this.episodePlaybackPositions().size) > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
|
||||
this.legacyPositionByTrackingId.set(
|
||||
reconciled.legacyPositionByTrackingId
|
||||
);
|
||||
this.applyReconciledSeriesPositions();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -723,11 +719,37 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private readonly vodSeasonEpisodeLoads = new Map<
|
||||
string,
|
||||
Promise<boolean>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Loads episodes for a specific VOD season
|
||||
* Loads episodes for a specific VOD season.
|
||||
*
|
||||
* Single-flight per season: a tab click, the spillover prefetch, the
|
||||
* quick-start recursion, and the series-toggle hydration can all ask for
|
||||
* the same season — a second concurrent request would duplicate portal
|
||||
* traffic, and its failure could abort a series toggle whose original
|
||||
* request succeeded.
|
||||
*/
|
||||
/** Resolves true when the portal answered, false when the request failed. */
|
||||
async loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
|
||||
loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
|
||||
const key = `${season.video_id}:${season.id}`;
|
||||
const inFlight = this.vodSeasonEpisodeLoads.get(key);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const load = this.fetchEpisodesForSeason(season).finally(() => {
|
||||
this.vodSeasonEpisodeLoads.delete(key);
|
||||
});
|
||||
this.vodSeasonEpisodeLoads.set(key, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
private async fetchEpisodesForSeason(
|
||||
season: VodSeriesSeasonVm
|
||||
): Promise<boolean> {
|
||||
// Set loading state in local signal
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const index = seasons.findIndex((s) => s.id === season.id);
|
||||
@@ -750,6 +772,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
newSeasons[newIndex] = {
|
||||
...newSeasons[newIndex],
|
||||
episodes: episodes,
|
||||
// Even an EMPTY answer marks the season loaded: the
|
||||
// portal spoke, so it must stop counting as "unloaded"
|
||||
// (label/verdict gating and series-toggle hydration).
|
||||
episodesLoaded: true,
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
@@ -1129,76 +1155,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
this.seasonWatchBatchRunning.set(true);
|
||||
try {
|
||||
// Enqueue every episode synchronously: each mutation chains on
|
||||
// the previous one's never-rejecting barrier, so the queue
|
||||
// serializes the writes (incl. per-episode legacy-row cleanup)
|
||||
// and reloads positions once after the whole chain drains.
|
||||
const outcomes = await Promise.all(
|
||||
request.requests.map((item) =>
|
||||
(item.nextPosition
|
||||
? this.persistSeriesPosition(
|
||||
playlistId,
|
||||
item.nextPosition
|
||||
)
|
||||
: this.clearSeriesPosition(
|
||||
playlistId,
|
||||
item.contentXtreamId
|
||||
)
|
||||
).then(
|
||||
() => true,
|
||||
// The scoped watched row was saved and published —
|
||||
// only the legacy-row cleanup failed. The episode IS
|
||||
// watched, so it must not count against the batch.
|
||||
(error: unknown) =>
|
||||
error instanceof
|
||||
StalkerSeriesPositionPartialSaveError
|
||||
)
|
||||
)
|
||||
await this.runWatchToggleBatch(
|
||||
request,
|
||||
playlistId,
|
||||
stillCurrent,
|
||||
SEASON_WATCH_FEEDBACK
|
||||
);
|
||||
|
||||
const failed = outcomes.filter((ok) => !ok).length;
|
||||
const succeeded = outcomes.length - failed;
|
||||
if (failed > 0) {
|
||||
this.logger.error(
|
||||
`Season watched toggle: ${failed} of ${outcomes.length} episodes failed`
|
||||
);
|
||||
}
|
||||
if (succeeded > 0) {
|
||||
// Partial successes changed rows too — the catalog badge
|
||||
// must follow even when the user already moved on. A failed
|
||||
// refresh must not break the feedback flow below.
|
||||
await this.catalogFacade
|
||||
?.refreshPositions(playlistId)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.warn(
|
||||
'Catalog position refresh failed',
|
||||
error
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!stillCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
this.notifySeasonWatchToggle(
|
||||
request.markWatched
|
||||
? 'XTREAM.SEASON_MARKED_WATCHED'
|
||||
: 'XTREAM.SEASON_MARKED_UNWATCHED',
|
||||
{ count: succeeded }
|
||||
);
|
||||
} else if (succeeded > 0) {
|
||||
this.notifySeasonWatchToggle(
|
||||
request.markWatched
|
||||
? 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL'
|
||||
: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
|
||||
{ count: succeeded, failed }
|
||||
);
|
||||
} else {
|
||||
this.notifySeasonWatchToggle(
|
||||
'XTREAM.SEASON_WATCH_UPDATE_FAILED'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.seasonWatchBatchRunning.set(false);
|
||||
}
|
||||
@@ -1217,6 +1179,280 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a season the portal has never answered for. A season that
|
||||
* answered with zero episodes is loaded-and-empty, not pending — treating
|
||||
* it as pending would keep the series label countless forever and make
|
||||
* every series toggle re-fetch it.
|
||||
*/
|
||||
private isSeasonHydrationPending(season: VodSeriesSeasonVm): boolean {
|
||||
return season.episodes.length === 0 && !season.episodesLoaded;
|
||||
}
|
||||
|
||||
/** Seasons whose episode lists still need a portal request (lazy VOD). */
|
||||
readonly hasUnloadedVodSeasons = computed(
|
||||
() =>
|
||||
this.isVodSeries() &&
|
||||
this.vodSeriesSeasons().some((season) =>
|
||||
this.isSeasonHydrationPending(season)
|
||||
)
|
||||
);
|
||||
|
||||
async handleSeriesPlaybackToggleRequested(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!playlistId || this.seasonWatchBatchRunning()) {
|
||||
return;
|
||||
}
|
||||
const pendingSeasons = this.isVodSeries()
|
||||
? this.vodSeriesSeasons().filter((season) =>
|
||||
this.isSeasonHydrationPending(season)
|
||||
)
|
||||
: [];
|
||||
// An empty request is only meaningful when unloaded seasons remain:
|
||||
// every loaded episode is watched, so the container could not build
|
||||
// a target list, but hydration below may still surface unwatched
|
||||
// episodes to mark.
|
||||
if (request.requests.length === 0 && pendingSeasons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seriesXtreamId = this.toSeriesId(this.displayItem()?.id ?? 0);
|
||||
const stillCurrent = () =>
|
||||
this.stalkerStore.currentPlaylist()?._id === playlistId &&
|
||||
this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId;
|
||||
|
||||
this.seasonWatchBatchRunning.set(true);
|
||||
try {
|
||||
let effective: SeasonContainerSeriesPlaybackToggleRequest | null =
|
||||
request;
|
||||
if (pendingSeasons.length > 0) {
|
||||
const hydrated = await this.hydrateSeasonsForSeriesToggle(
|
||||
pendingSeasons,
|
||||
stillCurrent
|
||||
);
|
||||
if (hydrated !== 'complete') {
|
||||
if (hydrated === 'failed' && stillCurrent()) {
|
||||
this.notifySeasonWatchToggle(
|
||||
SERIES_WATCH_FEEDBACK.failed
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The reconcile effect only flushes on the next change-
|
||||
// detection tick; rebuild the maps synchronously so the
|
||||
// batch below sees the hydrated episodes' scoped and legacy
|
||||
// rows (see applyReconciledSeriesPositions).
|
||||
this.applyReconciledSeriesPositions();
|
||||
effective = buildSeriesWatchToggleRequest({
|
||||
seasons: this.mappedSeasons(),
|
||||
seriesId: seriesXtreamId,
|
||||
playlistId,
|
||||
isEpisodeWatched: (episode) =>
|
||||
isPortalPlaybackWatched(
|
||||
this.episodePlaybackPositions().get(
|
||||
Number(episode.id)
|
||||
)
|
||||
),
|
||||
excludedEpisodeIds: this.seriesWatchExcludedIds(),
|
||||
// Keep the direction the user clicked; re-inference over
|
||||
// the now-complete data could flip a "mark" into an
|
||||
// unwatch when everything turned out watched.
|
||||
markWatched: request.markWatched,
|
||||
});
|
||||
if (!effective) {
|
||||
if (stillCurrent()) {
|
||||
this.notifySeasonWatchToggle(
|
||||
SERIES_WATCH_FEEDBACK.marked,
|
||||
{ count: 0 }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.runWatchToggleBatch(
|
||||
effective,
|
||||
playlistId,
|
||||
stillCurrent,
|
||||
SERIES_WATCH_FEEDBACK
|
||||
);
|
||||
} finally {
|
||||
this.seasonWatchBatchRunning.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleSeriesPlaybackToggleRequestedFromUi(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
): void {
|
||||
void this.handleSeriesPlaybackToggleRequested(request).catch(
|
||||
(error: unknown) => {
|
||||
this.logger.error(
|
||||
'Failed to toggle series watched state',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential on purpose: loadEpisodesForSeason snapshots the season VM
|
||||
* array before its writes, so concurrent calls clobber each other's
|
||||
* loading flags; and one request at a time keeps the portal load bounded.
|
||||
*/
|
||||
private async hydrateSeasonsForSeriesToggle(
|
||||
pendingSeasons: readonly VodSeriesSeasonVm[],
|
||||
stillCurrent: () => boolean
|
||||
): Promise<'complete' | 'failed' | 'superseded'> {
|
||||
for (const season of pendingSeasons) {
|
||||
// A tab click may have loaded this season meanwhile.
|
||||
const current = this.vodSeriesSeasons().find(
|
||||
(candidate) => candidate.id === season.id
|
||||
);
|
||||
if (!current || !this.isSeasonHydrationPending(current)) {
|
||||
continue;
|
||||
}
|
||||
const answered = await this.loadEpisodesForSeason(current);
|
||||
if (!stillCurrent()) {
|
||||
return 'superseded';
|
||||
}
|
||||
if (!answered) {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the exclusions the template binds into the season container
|
||||
* (playingEpisodeId / activeEpisodeId / openingEpisodeId): the episode
|
||||
* playing or launching is never bulk-marked, because its live position
|
||||
* ticks would immediately overwrite the full-progress row.
|
||||
*/
|
||||
private seriesWatchExcludedIds(): ReadonlySet<number> {
|
||||
const ids = [
|
||||
this.openingEpisodeId(),
|
||||
this.activeEpisodeId(),
|
||||
this.inlinePlayback()?.contentInfo?.contentXtreamId ?? null,
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
private async runWatchToggleBatch(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest,
|
||||
playlistId: string,
|
||||
stillCurrent: () => boolean,
|
||||
feedback: StalkerWatchToggleFeedback
|
||||
): Promise<void> {
|
||||
// Enqueue every episode synchronously: each mutation chains on
|
||||
// the previous one's never-rejecting barrier, so the queue
|
||||
// serializes the writes (incl. per-episode legacy-row cleanup)
|
||||
// and reloads positions once after the whole chain drains.
|
||||
const outcomes = await Promise.all(
|
||||
request.requests.map((item) =>
|
||||
(item.nextPosition
|
||||
? this.persistSeriesPosition(playlistId, item.nextPosition)
|
||||
: this.clearSeriesPosition(
|
||||
playlistId,
|
||||
item.contentXtreamId
|
||||
)
|
||||
).then(
|
||||
() => true,
|
||||
// The scoped watched row was saved and published —
|
||||
// only the legacy-row cleanup failed. The episode IS
|
||||
// watched, so it must not count against the batch.
|
||||
(error: unknown) =>
|
||||
error instanceof StalkerSeriesPositionPartialSaveError
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const failed = outcomes.filter((ok) => !ok).length;
|
||||
const succeeded = outcomes.length - failed;
|
||||
if (failed > 0) {
|
||||
this.logger.error(
|
||||
`Watched toggle: ${failed} of ${outcomes.length} episodes failed`
|
||||
);
|
||||
}
|
||||
if (succeeded > 0) {
|
||||
// Partial successes changed rows too — the catalog badge
|
||||
// must follow even when the user already moved on. A failed
|
||||
// refresh must not break the feedback flow below.
|
||||
await this.catalogFacade
|
||||
?.refreshPositions(playlistId)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.warn('Catalog position refresh failed', error)
|
||||
);
|
||||
}
|
||||
if (!stillCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
this.notifySeasonWatchToggle(
|
||||
request.markWatched ? feedback.marked : feedback.unmarked,
|
||||
{ count: succeeded }
|
||||
);
|
||||
} else if (succeeded > 0) {
|
||||
this.notifySeasonWatchToggle(
|
||||
request.markWatched
|
||||
? feedback.partialMarked
|
||||
: feedback.partialUnmarked,
|
||||
{ count: succeeded, failed }
|
||||
);
|
||||
} else {
|
||||
this.notifySeasonWatchToggle(feedback.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the raw series position rows onto the currently mapped episodes
|
||||
* (scoped rows plus compatible legacy promotions). Runs reactively from
|
||||
* the constructor effect, and synchronously from the series-level watch
|
||||
* toggle right after it hydrates lazy seasons — the effect only re-runs
|
||||
* on the next change-detection tick, and enqueuing the batch against the
|
||||
* stale maps would miss the hydrated episodes' legacy rows (an unwatch
|
||||
* would leave rows behind that a later reconcile resurrects as watched).
|
||||
*/
|
||||
private applyReconciledSeriesPositions(): void {
|
||||
const item = this.displayItem();
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
|
||||
const rawSeriesPositions = this.rawSeriesPositions();
|
||||
const episodesBySeason = this.mappedSeasons();
|
||||
|
||||
if (!item || !playlistId || seriesXtreamId <= 0) {
|
||||
if (rawSeriesPositions.length > 0) {
|
||||
this.rawSeriesPositions.set([]);
|
||||
}
|
||||
if (this.episodePlaybackPositions().size > 0) {
|
||||
this.episodePlaybackPositions.set(new Map());
|
||||
}
|
||||
if (this.legacyPositionByTrackingId().size > 0) {
|
||||
this.legacyPositionByTrackingId.set(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciled = reconcileStalkerSeriesPositions({
|
||||
seriesXtreamId,
|
||||
episodesBySeason,
|
||||
seriesPositions: rawSeriesPositions,
|
||||
});
|
||||
if (
|
||||
rawSeriesPositions.length === 0 &&
|
||||
reconciled.positionsByTrackingId.size === 0 &&
|
||||
untracked(() => this.episodePlaybackPositions().size) > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
|
||||
this.legacyPositionByTrackingId.set(
|
||||
reconciled.legacyPositionByTrackingId
|
||||
);
|
||||
}
|
||||
|
||||
private notifySeasonWatchToggle(key: string, params?: object): void {
|
||||
this.snackBar.open(
|
||||
this.translateService.instant(key, params),
|
||||
|
||||
+304
@@ -10,6 +10,8 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerStore,
|
||||
mapVodSeriesEpisodes,
|
||||
type StalkerMappedEpisode,
|
||||
type StalkerVodSource,
|
||||
type VodSeriesSeasonVm,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -397,4 +399,306 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
|
||||
expect(snackBarCalls().length).toBe(callsBefore);
|
||||
expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false);
|
||||
});
|
||||
|
||||
describe('series scope', () => {
|
||||
function createSecondSeason(
|
||||
episodes: VodSeriesSeasonVm['episodes'] = []
|
||||
): VodSeriesSeasonVm {
|
||||
return {
|
||||
id: 'season-2',
|
||||
video_id: String(SERIES_A_ID),
|
||||
name: 'Season 2',
|
||||
season_number: '2',
|
||||
episodes,
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The real mapper is the oracle for the tracking ids a hydrated
|
||||
// season will produce, so legacy rows can be seeded BEFORE the
|
||||
// toggle hydrates that season.
|
||||
const s2ProviderEpisode = () =>
|
||||
createProviderEpisode('provider-episode-s2', 1);
|
||||
const s2Mapped = mapVodSeriesEpisodes(
|
||||
[createSecondSeason([s2ProviderEpisode()])],
|
||||
{ parentSeriesId: SERIES_A_ID, fallbackPoster: '' }
|
||||
)['2'][0] as StalkerMappedEpisode;
|
||||
const S2_SCOPED_ID = Number(s2Mapped.id);
|
||||
const S2_LEGACY_ID = Number(s2Mapped.legacyTrackingId);
|
||||
|
||||
function seriesToggleRequest(ids: number[], markWatched: boolean) {
|
||||
return {
|
||||
markWatched,
|
||||
requests: ids.map((contentXtreamId, index) => ({
|
||||
contentXtreamId,
|
||||
nextPosition: markWatched
|
||||
? createPosition({
|
||||
contentXtreamId,
|
||||
episodeNumber: index + 1,
|
||||
positionSeconds: 100,
|
||||
})
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function startWithLazySecondSeason(): Promise<number> {
|
||||
vodSeriesSeasonsResource.set([
|
||||
{
|
||||
id: 'season-1',
|
||||
video_id: String(SERIES_A_ID),
|
||||
name: 'Season 1',
|
||||
season_number: '1',
|
||||
},
|
||||
]);
|
||||
await settle();
|
||||
fixture.componentInstance.vodSeriesSeasons.set([
|
||||
createSeason(SERIES_A_ID, [createProviderEpisode()]),
|
||||
createSecondSeason(),
|
||||
]);
|
||||
await settle();
|
||||
return Number(
|
||||
fixture.componentInstance.mappedSeasons()['1'][0].id
|
||||
);
|
||||
}
|
||||
|
||||
function mockSecondSeasonFetch(): jest.Mock {
|
||||
const fetch = TestBed.inject(StalkerStore)
|
||||
.fetchVodSeriesEpisodes as jest.Mock;
|
||||
fetch.mockResolvedValue([s2ProviderEpisode()]);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
it('marks every season directly when all seasons are loaded', async () => {
|
||||
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
|
||||
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId, secondId], true)
|
||||
);
|
||||
|
||||
expect(repositoryOrder).toEqual([
|
||||
`save:${firstId}`,
|
||||
`save:${secondId}`,
|
||||
]);
|
||||
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
|
||||
expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID);
|
||||
expect(
|
||||
fixture.componentInstance.seasonWatchBatchRunning()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hydrates lazy seasons, then marks them with legacy-row cleanup', async () => {
|
||||
// A pre-scope legacy row for the NOT-yet-hydrated season 2
|
||||
// episode: the sync reconcile after hydration must surface it so
|
||||
// the save cleans it up — enqueuing against the effect-fed maps
|
||||
// would miss it (the effect only flushes on the next CD tick).
|
||||
repositoryRows = [
|
||||
createPosition({
|
||||
contentXtreamId: S2_LEGACY_ID,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
}),
|
||||
];
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
const fetch = mockSecondSeasonFetch();
|
||||
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
String(SERIES_A_ID),
|
||||
'season-2'
|
||||
);
|
||||
expect(repositoryOrder).toEqual([
|
||||
`save:${firstId}`,
|
||||
`save:${S2_SCOPED_ID}`,
|
||||
`clear:${S2_LEGACY_ID}`,
|
||||
]);
|
||||
expect(
|
||||
repositoryRows.some(
|
||||
(row) => row.contentXtreamId === S2_LEGACY_ID
|
||||
)
|
||||
).toBe(false);
|
||||
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
|
||||
expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID);
|
||||
});
|
||||
|
||||
it('treats an empty portal answer as loaded instead of eternally pending', async () => {
|
||||
// The portal ANSWERS for season 2 but reports zero episodes. The
|
||||
// season is then loaded-and-empty, not pending: the rest of the
|
||||
// series still gets marked, the countless label unblocks, and a
|
||||
// follow-up toggle must not re-fetch the empty season.
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
const fetch = TestBed.inject(StalkerStore)
|
||||
.fetchVodSeriesEpisodes as jest.Mock;
|
||||
fetch.mockResolvedValue([]);
|
||||
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryOrder).toEqual([`save:${firstId}`]);
|
||||
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
|
||||
expect(
|
||||
fixture.componentInstance.hasUnloadedVodSeasons()
|
||||
).toBe(false);
|
||||
|
||||
// Everything loaded is now watched and nothing is pending — an
|
||||
// empty follow-up mark request is a no-op, not another fetch.
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([], true)
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryOrder).toEqual([`save:${firstId}`]);
|
||||
});
|
||||
|
||||
it('reuses an in-flight tab-click season load instead of duplicating it', async () => {
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
let releaseFetch!: (episodes: unknown[]) => void;
|
||||
const fetch = TestBed.inject(StalkerStore)
|
||||
.fetchVodSeriesEpisodes as jest.Mock;
|
||||
fetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
// The user opened the season tab; that load is still on the
|
||||
// wire when the series action starts hydrating — it must join
|
||||
// the in-flight request, not start a second one whose failure
|
||||
// could abort the toggle independently.
|
||||
const season2 = fixture.componentInstance.vodSeriesSeasons()[1];
|
||||
const tabLoad =
|
||||
fixture.componentInstance.loadEpisodesForSeason(season2);
|
||||
const pending =
|
||||
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
releaseFetch([s2ProviderEpisode()]);
|
||||
await Promise.all([tabLoad, pending]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryOrder).toEqual([
|
||||
`save:${firstId}`,
|
||||
`save:${S2_SCOPED_ID}`,
|
||||
]);
|
||||
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
|
||||
});
|
||||
|
||||
it('aborts with zero writes when hydrating a season fails', async () => {
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
(
|
||||
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
|
||||
).mockRejectedValue(new Error('portal down'));
|
||||
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
|
||||
expect(repositoryOrder).toEqual([]);
|
||||
expectSeasonToggleSnackbar('XTREAM.SERIES_WATCH_UPDATE_FAILED');
|
||||
expect(refreshPositions).not.toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.componentInstance.seasonWatchBatchRunning()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts silently when the series changes during hydration', async () => {
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
let releaseFetch!: (episodes: unknown[]) => void;
|
||||
(
|
||||
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
|
||||
).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
})
|
||||
);
|
||||
const callsBefore = snackBarCalls().length;
|
||||
|
||||
const pending =
|
||||
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
selectedItem.set(createVodItem(SERIES_B_ID));
|
||||
releaseFetch([s2ProviderEpisode()]);
|
||||
await pending;
|
||||
|
||||
expect(repositoryOrder).toEqual([]);
|
||||
expect(snackBarCalls().length).toBe(callsBefore);
|
||||
expect(
|
||||
fixture.componentInstance.seasonWatchBatchRunning()
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a zero count when hydration reveals nothing to mark', async () => {
|
||||
// Every episode — loaded and hydrated alike — is already
|
||||
// watched, but the empty mark request must still be honored
|
||||
// (the container cannot know before hydration).
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
repositoryRows = [
|
||||
createPosition({
|
||||
contentXtreamId: firstId,
|
||||
positionSeconds: 100,
|
||||
}),
|
||||
createPosition({
|
||||
contentXtreamId: S2_SCOPED_ID,
|
||||
seasonNumber: 2,
|
||||
positionSeconds: 100,
|
||||
}),
|
||||
];
|
||||
selectedItem.set(createVodItem(SERIES_A_ID));
|
||||
await settle();
|
||||
mockSecondSeasonFetch();
|
||||
const writesBefore = repositoryOrder.length;
|
||||
|
||||
await fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([], true)
|
||||
);
|
||||
|
||||
expect(repositoryOrder.length).toBe(writesBefore);
|
||||
expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED');
|
||||
});
|
||||
|
||||
it('locks the season toggle while hydration is in flight', async () => {
|
||||
const firstId = await startWithLazySecondSeason();
|
||||
let releaseFetch!: (episodes: unknown[]) => void;
|
||||
(
|
||||
TestBed.inject(StalkerStore).fetchVodSeriesEpisodes as jest.Mock
|
||||
).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const pending =
|
||||
fixture.componentInstance.handleSeriesPlaybackToggleRequested(
|
||||
seriesToggleRequest([firstId], true)
|
||||
);
|
||||
expect(
|
||||
fixture.componentInstance.seasonWatchBatchRunning()
|
||||
).toBe(true);
|
||||
// A season toggle during hydration must not enqueue anything.
|
||||
await fixture.componentInstance.handleSeasonPlaybackToggleRequested(
|
||||
seasonToggleRequest([firstId], true)
|
||||
);
|
||||
expect(repositoryOrder).toEqual([]);
|
||||
|
||||
releaseFetch([s2ProviderEpisode()]);
|
||||
await pending;
|
||||
|
||||
expect(repositoryOrder).toEqual([
|
||||
`save:${firstId}`,
|
||||
`save:${S2_SCOPED_ID}`,
|
||||
]);
|
||||
expect(
|
||||
fixture.componentInstance.seasonWatchBatchRunning()
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-5
@@ -27,7 +27,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
SeasonContainerSeriesPlaybackToggleRequest,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
getSeriesEpisodeMetadata,
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
|
||||
import {
|
||||
SerialDetailsSeasonWatchService,
|
||||
type SerialDetailsWatchScope,
|
||||
} from './serial-details-season-watch.service';
|
||||
|
||||
export type XtreamSerieDetailsView = XtreamSerieDetails & {
|
||||
readonly series_id: number;
|
||||
@@ -343,8 +346,9 @@ export class SerialDetailsPlaybackService {
|
||||
await this.refreshStorePositions(playlistId);
|
||||
}
|
||||
|
||||
async handleSeasonPlaybackToggleRequested(
|
||||
request: SeasonContainerSeasonPlaybackToggleRequest
|
||||
async handleWatchToggleRequested(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest,
|
||||
scope: SerialDetailsWatchScope
|
||||
): Promise<void> {
|
||||
const playlistId = this.currentPlaylistId();
|
||||
const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0);
|
||||
@@ -354,7 +358,8 @@ export class SerialDetailsPlaybackService {
|
||||
this.playbackPositionState,
|
||||
() =>
|
||||
this.currentPlaylistId() === playlistId &&
|
||||
Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId
|
||||
Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId,
|
||||
scope
|
||||
);
|
||||
if (persisted) {
|
||||
await this.refreshStorePositions(playlistId);
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
|
||||
|
||||
describe('SerialDetailsSeasonWatchService', () => {
|
||||
let service: SerialDetailsSeasonWatchService;
|
||||
const savePlaybackPositionsBatch = jest.fn();
|
||||
const clearPlaybackPositionsBatch = jest.fn();
|
||||
const snackBarOpen = jest.fn();
|
||||
const state = { updateMany: jest.fn(), removeMany: jest.fn() };
|
||||
|
||||
const position = (contentXtreamId: number): PlaybackPositionData => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: contentXtreamId % 100,
|
||||
positionSeconds: 2700,
|
||||
durationSeconds: 2700,
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
const markRequest = (ids: number[]) => ({
|
||||
markWatched: true,
|
||||
requests: ids.map((id) => ({
|
||||
contentXtreamId: id,
|
||||
nextPosition: position(id),
|
||||
})),
|
||||
});
|
||||
|
||||
const clearRequest = (ids: number[]) => ({
|
||||
markWatched: false,
|
||||
requests: ids.map((id) => ({
|
||||
contentXtreamId: id,
|
||||
nextPosition: null,
|
||||
})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
savePlaybackPositionsBatch.mockResolvedValue(undefined);
|
||||
clearPlaybackPositionsBatch.mockResolvedValue(undefined);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SerialDetailsSeasonWatchService,
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
savePlaybackPositionsBatch,
|
||||
clearPlaybackPositionsBatch,
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: snackBarOpen } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string, params?: object) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(SerialDetailsSeasonWatchService);
|
||||
});
|
||||
|
||||
it('marks a season through one batch save with season feedback', async () => {
|
||||
const persisted = await service.handle(
|
||||
markRequest([101, 102]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'season'
|
||||
);
|
||||
|
||||
expect(persisted).toBe(true);
|
||||
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('playlist-1', [
|
||||
expect.objectContaining({ contentXtreamId: 101 }),
|
||||
expect.objectContaining({ contentXtreamId: 102 }),
|
||||
]);
|
||||
expect(state.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.SEASON_MARKED_WATCHED:{"count":2}',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the scope-generic marked key for a series mark', async () => {
|
||||
await service.handle(
|
||||
markRequest([101, 201]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.SEASON_MARKED_WATCHED:{"count":2}',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('unmarks a series through one batch clear with series feedback', async () => {
|
||||
const persisted = await service.handle(
|
||||
clearRequest([101, 201]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(persisted).toBe(true);
|
||||
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
[
|
||||
{ contentXtreamId: 101, contentType: 'episode' },
|
||||
{ contentXtreamId: 201, contentType: 'episode' },
|
||||
]
|
||||
);
|
||||
expect(state.removeMany).toHaveBeenCalledWith([101, 201]);
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.SERIES_MARKED_UNWATCHED',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the season unmark feedback on the season scope', async () => {
|
||||
await service.handle(
|
||||
clearRequest([101]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'season'
|
||||
);
|
||||
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.SEASON_MARKED_UNWATCHED',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a series failure with the series key and keeps state', async () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
savePlaybackPositionsBatch.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const persisted = await service.handle(
|
||||
markRequest([101]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(persisted).toBe(false);
|
||||
expect(state.updateMany).not.toHaveBeenCalled();
|
||||
expect(snackBarOpen).toHaveBeenCalledWith(
|
||||
'XTREAM.SERIES_WATCH_UPDATE_FAILED',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('suppresses state writes and feedback after navigation, still reporting persisted', async () => {
|
||||
const persisted = await service.handle(
|
||||
markRequest([101]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => false,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(persisted).toBe(true);
|
||||
expect(state.updateMany).not.toHaveBeenCalled();
|
||||
expect(snackBarOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty requests and re-entrant calls', async () => {
|
||||
expect(
|
||||
await service.handle(
|
||||
{ markWatched: true, requests: [] },
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'series'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
let release!: () => void;
|
||||
savePlaybackPositionsBatch.mockImplementation(
|
||||
() => new Promise<void>((resolve) => (release = resolve))
|
||||
);
|
||||
const pending = service.handle(
|
||||
markRequest([101]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'season'
|
||||
);
|
||||
expect(service.batchRunning()).toBe(true);
|
||||
expect(
|
||||
await service.handle(
|
||||
markRequest([102]),
|
||||
'playlist-1',
|
||||
state,
|
||||
() => true,
|
||||
'series'
|
||||
)
|
||||
).toBe(false);
|
||||
release();
|
||||
await pending;
|
||||
expect(service.batchRunning()).toBe(false);
|
||||
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+29
-9
@@ -3,12 +3,29 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { SeasonContainerSeasonPlaybackToggleRequest } from '@iptvnator/ui/components';
|
||||
import { SeasonContainerSeriesPlaybackToggleRequest } from '@iptvnator/ui/components';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
|
||||
// The marked-watched key is scope-generic on purpose ("{{count}} episodes
|
||||
// marked as watched"); only unmark and failure name their scope.
|
||||
const WATCH_TOGGLE_FEEDBACK = {
|
||||
season: {
|
||||
marked: 'XTREAM.SEASON_MARKED_WATCHED',
|
||||
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
|
||||
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
|
||||
},
|
||||
series: {
|
||||
marked: 'XTREAM.SEASON_MARKED_WATCHED',
|
||||
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
|
||||
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SerialDetailsWatchScope = keyof typeof WATCH_TOGGLE_FEEDBACK;
|
||||
|
||||
/**
|
||||
* Season-level bulk watched toggle for the serial details view: one batch
|
||||
* persistence call, host state update, and user feedback.
|
||||
* Season- and series-level bulk watched toggle for the serial details view:
|
||||
* one batch persistence call, host state update, and user feedback.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SerialDetailsSeasonWatchService {
|
||||
@@ -19,7 +36,7 @@ export class SerialDetailsSeasonWatchService {
|
||||
readonly batchRunning = signal(false);
|
||||
|
||||
async handle(
|
||||
request: SeasonContainerSeasonPlaybackToggleRequest,
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest,
|
||||
playlistId: string,
|
||||
state: Pick<
|
||||
SerialDetailsPlaybackPositionState,
|
||||
@@ -31,8 +48,10 @@ export class SerialDetailsSeasonWatchService {
|
||||
// into it (episode ids can collide across playlists) nor present
|
||||
// its contextless snackbar as feedback about the newly opened page.
|
||||
// The DB write itself is safe — it carries its own playlistId.
|
||||
stillCurrent: () => boolean
|
||||
stillCurrent: () => boolean,
|
||||
scope: SerialDetailsWatchScope = 'season'
|
||||
): Promise<boolean> {
|
||||
const feedback = WATCH_TOGGLE_FEEDBACK[scope];
|
||||
if (!playlistId || request.requests.length === 0 || this.batchRunning()) {
|
||||
return false;
|
||||
}
|
||||
@@ -54,7 +73,7 @@ export class SerialDetailsSeasonWatchService {
|
||||
return true;
|
||||
}
|
||||
state.updateMany(positions);
|
||||
this.notify('XTREAM.SEASON_MARKED_WATCHED', {
|
||||
this.notify(feedback.marked, {
|
||||
count: positions.length,
|
||||
});
|
||||
} else {
|
||||
@@ -71,18 +90,19 @@ export class SerialDetailsSeasonWatchService {
|
||||
state.removeMany(
|
||||
request.requests.map((item) => item.contentXtreamId)
|
||||
);
|
||||
this.notify('XTREAM.SEASON_MARKED_UNWATCHED');
|
||||
this.notify(feedback.unmarked);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Nothing was confirmed persisted — keep the rendered state and
|
||||
// report instead of showing episodes as (un)watched.
|
||||
console.error(
|
||||
'[SerialDetailsSeasonWatch] Season watched toggle failed',
|
||||
'[SerialDetailsSeasonWatch] Watched toggle failed',
|
||||
{ scope },
|
||||
error
|
||||
);
|
||||
if (stillCurrent()) {
|
||||
this.notify('XTREAM.SEASON_WATCH_UPDATE_FAILED');
|
||||
this.notify(feedback.failed);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -221,6 +221,9 @@
|
||||
(seasonPlaybackToggleRequested)="
|
||||
handleSeasonPlaybackToggleRequested($event)
|
||||
"
|
||||
(seriesPlaybackToggleRequested)="
|
||||
handleSeriesPlaybackToggleRequested($event)
|
||||
"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
/>
|
||||
|
||||
|
||||
+68
-50
@@ -923,14 +923,23 @@ describe('SerialDetailsComponent', () => {
|
||||
durationSeconds: 1200,
|
||||
});
|
||||
|
||||
await playbackService.handleSeasonPlaybackToggleRequested({
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{ contentXtreamId: 1001, nextPosition: seasonPosition(1001, 1) },
|
||||
{ contentXtreamId: 1002, nextPosition: seasonPosition(1002, 2) },
|
||||
],
|
||||
} as never);
|
||||
await playbackService.handleWatchToggleRequested(
|
||||
{
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
nextPosition: seasonPosition(1001, 1),
|
||||
},
|
||||
{
|
||||
contentXtreamId: 1002,
|
||||
nextPosition: seasonPosition(1002, 2),
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
'season'
|
||||
);
|
||||
|
||||
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
|
||||
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
|
||||
@@ -961,14 +970,17 @@ describe('SerialDetailsComponent', () => {
|
||||
const playbackService = fixture.debugElement.injector.get(
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
await playbackService.handleSeasonPlaybackToggleRequested({
|
||||
seasonKey: '1',
|
||||
markWatched: false,
|
||||
requests: [
|
||||
{ contentXtreamId: 1001, nextPosition: null },
|
||||
{ contentXtreamId: 1002, nextPosition: null },
|
||||
],
|
||||
} as never);
|
||||
await playbackService.handleWatchToggleRequested(
|
||||
{
|
||||
seasonKey: '1',
|
||||
markWatched: false,
|
||||
requests: [
|
||||
{ contentXtreamId: 1001, nextPosition: null },
|
||||
{ contentXtreamId: 1002, nextPosition: null },
|
||||
],
|
||||
} as never,
|
||||
'season'
|
||||
);
|
||||
|
||||
expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1);
|
||||
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
|
||||
@@ -995,25 +1007,28 @@ describe('SerialDetailsComponent', () => {
|
||||
const playbackService = fixture.debugElement.injector.get(
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
const pending = playbackService.handleSeasonPlaybackToggleRequested({
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
nextPosition: {
|
||||
playlistId: 'xtream-1',
|
||||
const pending = playbackService.handleWatchToggleRequested(
|
||||
{
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 1200,
|
||||
durationSeconds: 1200,
|
||||
nextPosition: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 1200,
|
||||
durationSeconds: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
],
|
||||
} as never,
|
||||
'season'
|
||||
);
|
||||
|
||||
// The user navigates to another playlist while the batch is pending.
|
||||
const initialPlaylist = currentPlaylist();
|
||||
@@ -1049,25 +1064,28 @@ describe('SerialDetailsComponent', () => {
|
||||
SerialDetailsPlaybackService
|
||||
);
|
||||
const snackBar = TestBed.inject(MatSnackBar);
|
||||
await playbackService.handleSeasonPlaybackToggleRequested({
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
nextPosition: {
|
||||
playlistId: 'xtream-1',
|
||||
await playbackService.handleWatchToggleRequested(
|
||||
{
|
||||
seasonKey: '1',
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 1200,
|
||||
durationSeconds: 1200,
|
||||
nextPosition: {
|
||||
playlistId: 'xtream-1',
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 1200,
|
||||
durationSeconds: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
],
|
||||
} as never,
|
||||
'season'
|
||||
);
|
||||
|
||||
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
|
||||
false
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
SeasonContainerSeriesPlaybackToggleRequest,
|
||||
} from '@iptvnator/ui/components';
|
||||
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
@@ -394,7 +395,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
handleSeasonPlaybackToggleRequested(
|
||||
request: SeasonContainerSeasonPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
return this.playback.handleSeasonPlaybackToggleRequested(request);
|
||||
return this.playback.handleWatchToggleRequested(request, 'season');
|
||||
}
|
||||
|
||||
handleSeriesPlaybackToggleRequested(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
return this.playback.handleWatchToggleRequested(request, 'series');
|
||||
}
|
||||
|
||||
toggleFavorite(): void {
|
||||
|
||||
@@ -18,6 +18,8 @@ export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/season-container/season-watch-presenter';
|
||||
export * from './lib/season-container/season-watch-toggle.util';
|
||||
export * from './lib/vod-sources/vod-source-copy-row.component';
|
||||
export * from './lib/vod-sources/vod-source-filtering.util';
|
||||
export * from './lib/vod-sources/vod-source-row.component';
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
[batchRunning]="downloadPresenter.batchRunning()"
|
||||
[seasonDisabled]="downloadPresenter.seasonDisabled()"
|
||||
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
|
||||
[watchToggleVisible]="seasonWatchToggleVisible()"
|
||||
[watchToggleVisible]="watchPresenter.seasonWatchToggleVisible()"
|
||||
[watchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[seasonFullyWatched]="selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="seasonWatchEligibleCount()"
|
||||
[seasonFullyWatched]="watchPresenter.selectedSeasonFullyWatched()"
|
||||
[watchEligibleCount]="watchPresenter.seasonWatchEligibleCount()"
|
||||
[seriesMenuVisible]="watchPresenter.seriesMenuVisible()"
|
||||
[seriesActionDisabled]="watchPresenter.seriesActionDisabled()"
|
||||
[seriesFullyWatched]="watchPresenter.seriesFullyWatched()"
|
||||
[seriesEligibleCount]="watchPresenter.seriesWatchEligibleCount()"
|
||||
[seriesCountKnown]="watchPresenter.seriesCountKnown()"
|
||||
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
|
||||
[viewMode]="viewMode()"
|
||||
(downloadSeason)="downloadPresenter.enqueueSeason()"
|
||||
(toggleSeasonWatched)="toggleSeasonWatched()"
|
||||
(toggleSeasonWatched)="watchPresenter.toggleSeasonWatched()"
|
||||
(toggleSeriesWatched)="watchPresenter.toggleSeriesWatched()"
|
||||
(viewModeChange)="setViewMode($event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -42,28 +42,23 @@ import {
|
||||
SeasonHeaderComponent,
|
||||
} from './season-header.component';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import { SeasonWatchPresenter } from './season-watch-presenter';
|
||||
import {
|
||||
type SeasonContainerPlaybackToggleRequest,
|
||||
type SeasonContainerSeasonPlaybackToggleRequest,
|
||||
buildSeasonWatchToggleRequest,
|
||||
type SeasonContainerSeriesPlaybackToggleRequest,
|
||||
buildWatchedEpisodePosition,
|
||||
listMarkableEpisodes,
|
||||
resolveEpisodeInfo,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
export type {
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerSeasonPlaybackToggleRequest,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter],
|
||||
providers: [SeasonDownloadPresenter, SeasonWatchPresenter],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
@@ -81,6 +76,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
readonly watchPresenter = inject(SeasonWatchPresenter);
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -101,12 +97,20 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
/**
|
||||
* True while some seasons' episode lists are not loaded yet (Stalker
|
||||
* lazy-VOD): blocks the series-wide "fully watched" verdict and hides the
|
||||
* count from the series action label.
|
||||
*/
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly playbackToggleRequested =
|
||||
output<SeasonContainerPlaybackToggleRequest>();
|
||||
readonly seasonPlaybackToggleRequested =
|
||||
output<SeasonContainerSeasonPlaybackToggleRequest>();
|
||||
readonly seriesPlaybackToggleRequested =
|
||||
output<SeasonContainerSeriesPlaybackToggleRequest>();
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
@@ -152,51 +156,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
readonly selectedSeasonUnwatchedCount = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().filter(
|
||||
(episode) => !this.isEpisodeWatched(episode)
|
||||
).length
|
||||
);
|
||||
|
||||
readonly selectedSeasonFullyWatched = computed(
|
||||
() =>
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
this.selectedSeasonUnwatchedCount() === 0
|
||||
);
|
||||
|
||||
/**
|
||||
* The episode currently playing (inline or externally) or launching is
|
||||
* never bulk-marked: the player persists its live position and would
|
||||
* immediately overwrite the full-progress row.
|
||||
*/
|
||||
private readonly seasonWatchExcludedIds = computed(() => {
|
||||
const ids = [
|
||||
this.playingEpisodeId(),
|
||||
this.activeEpisodeId(),
|
||||
this.openingEpisodeId(),
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
});
|
||||
|
||||
/** Count shown in the season toggle label: episodes the action touches. */
|
||||
readonly seasonWatchEligibleCount = computed(() =>
|
||||
this.selectedSeasonFullyWatched()
|
||||
? this.selectedSeasonEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
this.selectedSeasonEpisodes(),
|
||||
(episode) => this.isEpisodeWatched(episode),
|
||||
this.seasonWatchExcludedIds()
|
||||
).length
|
||||
);
|
||||
|
||||
readonly seasonWatchToggleVisible = computed(
|
||||
() =>
|
||||
this.sortedSeasonKeys().length > 0 &&
|
||||
this.selectedSeasonEpisodes().length > 0 &&
|
||||
!!this.playlistId()
|
||||
);
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
@@ -239,6 +198,24 @@ export class SeasonContainerComponent implements OnInit {
|
||||
selectedSeason: this.selectedSeason,
|
||||
});
|
||||
|
||||
this.watchPresenter.connect({
|
||||
seasons: this.seasons,
|
||||
selectedSeason: this.selectedSeason,
|
||||
selectedSeasonEpisodes: this.selectedSeasonEpisodes,
|
||||
seriesId: this.seriesId,
|
||||
playlistId: this.playlistId,
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons,
|
||||
batchRunning: this.seasonWatchBatchRunning,
|
||||
playingEpisodeId: this.playingEpisodeId,
|
||||
activeEpisodeId: this.activeEpisodeId,
|
||||
openingEpisodeId: this.openingEpisodeId,
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
emitSeasonToggle: (request) =>
|
||||
this.seasonPlaybackToggleRequested.emit(request),
|
||||
emitSeriesToggle: (request) =>
|
||||
this.seriesPlaybackToggleRequested.emit(request),
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
@@ -359,29 +336,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
toggleSeasonWatched() {
|
||||
const seasonKey = this.selectedSeason();
|
||||
if (!seasonKey || !this.playlistId()) {
|
||||
this.logger.warn('Cannot toggle season watched: no season/playlist');
|
||||
return;
|
||||
}
|
||||
if (this.seasonWatchBatchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: this.selectedSeasonEpisodes(),
|
||||
seasonKey,
|
||||
seriesId: this.seriesId(),
|
||||
playlistId: this.playlistId(),
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
excludedEpisodeIds: this.seasonWatchExcludedIds(),
|
||||
});
|
||||
if (request) {
|
||||
this.seasonPlaybackToggleRequested.emit(request);
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import {
|
||||
type InterpolatableTranslationObject,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { of } from 'rxjs';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
import { type SeasonContainerSeriesPlaybackToggleRequest } from './season-watch-toggle.util';
|
||||
|
||||
// Series-scope coverage for the season-header ⋮ menu. The season-scope
|
||||
// toggle cases live in season-container.component.spec.ts, which sits at
|
||||
// the max-lines cap.
|
||||
|
||||
const EN_TRANSLATIONS = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(process.cwd(), 'apps/web/src/assets/i18n/en.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as InterpolatableTranslationObject;
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 0,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
const watchedPosition = (contentXtreamId: number) => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode' as const,
|
||||
seriesXtreamId: 20,
|
||||
positionSeconds: 100,
|
||||
durationSeconds: 100,
|
||||
playlistId: 'playlist-1',
|
||||
});
|
||||
|
||||
describe('SeasonContainerComponent series watched toggle', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
let emitted: SeasonContainerSeriesPlaybackToggleRequest[];
|
||||
|
||||
const twoSeasons = () => ({
|
||||
'1': [
|
||||
createEpisode({ id: '101', episode_num: 1 }),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
],
|
||||
'2': [createEpisode({ id: '201', episode_num: 1 })],
|
||||
});
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
playlistId = 'playlist-1'
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', playlistId);
|
||||
};
|
||||
|
||||
const menuTrigger = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="series-watch-menu"]'
|
||||
);
|
||||
|
||||
const openSeriesMenuItem = (): HTMLButtonElement => {
|
||||
const trigger = menuTrigger();
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.click();
|
||||
fixture.detectChanges();
|
||||
const item = document.querySelector(
|
||||
'[data-test-id="toggle-series-watched"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(item).not.toBeNull();
|
||||
return item as HTMLButtonElement;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
XTREAM: EN_TRANSLATIONS['XTREAM'],
|
||||
} as InterpolatableTranslationObject);
|
||||
translate.setDefaultLang('en');
|
||||
translate.use('en');
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
emitted = [];
|
||||
component.seriesPlaybackToggleRequested.subscribe((request) =>
|
||||
emitted.push(request)
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the menu without a playlist id or without seasons', () => {
|
||||
setRequiredInputs(twoSeasons(), '');
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
setRequiredInputs({});
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
// Loaded-but-empty seasons only count with the unloaded marker.
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('hasUnloadedSeasons', true);
|
||||
fixture.detectChanges();
|
||||
expect(menuTrigger()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('marks every unwatched episode across seasons, excluding the playing one', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.componentRef.setInput('playingEpisodeId', 102);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
// 3 episodes − 1 watched − 1 playing = 1 markable.
|
||||
expect(item.textContent).toContain('Mark series as watched (1)');
|
||||
item.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
markWatched: true,
|
||||
requests: [
|
||||
{
|
||||
contentXtreamId: 201,
|
||||
nextPosition: expect.objectContaining({
|
||||
contentXtreamId: 201,
|
||||
seasonNumber: 2,
|
||||
seriesXtreamId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flips to unwatch-all when every season is watched and clears the playing episode too', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[101, watchedPosition(101)],
|
||||
[102, watchedPosition(102)],
|
||||
[201, watchedPosition(201)],
|
||||
])
|
||||
);
|
||||
fixture.componentRef.setInput('playingEpisodeId', 102);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
expect(item.textContent).toContain('Mark series as unwatched');
|
||||
item.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
markWatched: false,
|
||||
requests: [
|
||||
{ contentXtreamId: 101, nextPosition: null },
|
||||
{ contentXtreamId: 102, nextPosition: null },
|
||||
{ contentXtreamId: 201, nextPosition: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the countless label and emits an empty mark request when unloaded seasons hide the total', () => {
|
||||
setRequiredInputs({ '1': [createEpisode({ id: '101' })] });
|
||||
fixture.componentRef.setInput('hasUnloadedSeasons', true);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([[101, watchedPosition(101)]])
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const item = openSeriesMenuItem();
|
||||
expect(item.textContent).toContain('Mark series as watched');
|
||||
expect(item.textContent).not.toContain('(');
|
||||
item.click();
|
||||
|
||||
// Every loaded episode is watched — the host hydrates and rebuilds.
|
||||
expect(emitted).toEqual([{ markWatched: true, requests: [] }]);
|
||||
});
|
||||
|
||||
it('disables the action while a batch runs or nothing is actionable', () => {
|
||||
setRequiredInputs(twoSeasons());
|
||||
fixture.componentRef.setInput('seasonWatchBatchRunning', true);
|
||||
fixture.detectChanges();
|
||||
expect(openSeriesMenuItem().disabled).toBe(true);
|
||||
|
||||
// Single unwatched episode which is currently playing: nothing to
|
||||
// mark, series not fully watched, nothing unloaded.
|
||||
fixture.componentRef.setInput('seasonWatchBatchRunning', false);
|
||||
setRequiredInputs({ '1': [createEpisode({ id: '101' })] });
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
fixture.detectChanges();
|
||||
expect(openSeriesMenuItem().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (downloadVisible() || watchToggleVisible() || viewToggleVisible()) {
|
||||
@if (
|
||||
downloadVisible() ||
|
||||
watchToggleVisible() ||
|
||||
viewToggleVisible() ||
|
||||
seriesMenuVisible()
|
||||
) {
|
||||
<div class="section-header__actions">
|
||||
@if (watchToggleVisible()) {
|
||||
<button
|
||||
@@ -104,6 +109,47 @@
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
@if (seriesMenuVisible()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="series-watch-menu-trigger"
|
||||
data-test-id="series-watch-menu"
|
||||
[matMenuTriggerFor]="seriesWatchMenu"
|
||||
[attr.aria-label]="'XTREAM.SERIES_WATCH_MENU' | translate"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seriesWatchMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
class="series-watch-menu-item"
|
||||
data-test-id="toggle-series-watched"
|
||||
[disabled]="seriesActionDisabled()"
|
||||
[attr.aria-busy]="watchBatchRunning() ? 'true' : null"
|
||||
(click)="toggleSeriesWatched.emit()"
|
||||
>
|
||||
@if (watchBatchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
seriesFullyWatched()
|
||||
? 'remove_done'
|
||||
: 'done_all'
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
seriesActionLabelKey()
|
||||
| translate
|
||||
: { count: seriesEligibleCount() }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -94,6 +94,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.series-watch-menu-trigger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.series-watch-menu-item mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -22,6 +23,7 @@ export type EpisodeViewMode = 'grid' | 'list';
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
@@ -36,10 +38,26 @@ export class SeasonHeaderComponent {
|
||||
readonly watchBatchRunning = input.required<boolean>();
|
||||
readonly seasonFullyWatched = input.required<boolean>();
|
||||
readonly watchEligibleCount = input.required<number>();
|
||||
readonly seriesMenuVisible = input.required<boolean>();
|
||||
readonly seriesActionDisabled = input.required<boolean>();
|
||||
readonly seriesFullyWatched = input.required<boolean>();
|
||||
readonly seriesEligibleCount = input.required<number>();
|
||||
/** False while unloaded seasons make the series count a guess. */
|
||||
readonly seriesCountKnown = input.required<boolean>();
|
||||
readonly viewToggleVisible = input.required<boolean>();
|
||||
readonly viewMode = input.required<EpisodeViewMode>();
|
||||
|
||||
readonly downloadSeason = output<void>();
|
||||
readonly toggleSeasonWatched = output<void>();
|
||||
readonly toggleSeriesWatched = output<void>();
|
||||
readonly viewModeChange = output<EpisodeViewMode>();
|
||||
|
||||
seriesActionLabelKey(): string {
|
||||
if (this.seriesFullyWatched()) {
|
||||
return 'XTREAM.MARK_SERIES_UNWATCHED';
|
||||
}
|
||||
return this.seriesCountKnown()
|
||||
? 'XTREAM.MARK_SERIES_WATCHED'
|
||||
: 'XTREAM.MARK_SERIES_WATCHED_ALL';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Injectable, type Signal, computed, signal } from '@angular/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type SeasonContainerSeasonPlaybackToggleRequest,
|
||||
type SeasonContainerSeriesPlaybackToggleRequest,
|
||||
buildSeasonWatchToggleRequest,
|
||||
buildSeriesWatchToggleRequest,
|
||||
listMarkableEpisodes,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
export interface SeasonWatchPresenterSources {
|
||||
readonly seasons: Signal<Record<string, XtreamSerieEpisode[]>>;
|
||||
readonly selectedSeason: Signal<string | undefined>;
|
||||
readonly selectedSeasonEpisodes: Signal<readonly XtreamSerieEpisode[]>;
|
||||
readonly seriesId: Signal<number>;
|
||||
readonly playlistId: Signal<string>;
|
||||
/**
|
||||
* True while some seasons exist whose episode lists are not loaded yet
|
||||
* (Stalker lazy-VOD). Blocks the "fully watched" verdict and switches the
|
||||
* series label to its countless variant.
|
||||
*/
|
||||
readonly hasUnloadedSeasons: Signal<boolean>;
|
||||
readonly batchRunning: Signal<boolean>;
|
||||
readonly playingEpisodeId: Signal<number | null>;
|
||||
readonly activeEpisodeId: Signal<number | null>;
|
||||
readonly openingEpisodeId: Signal<number | null>;
|
||||
readonly isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean;
|
||||
readonly emitSeasonToggle: (
|
||||
request: SeasonContainerSeasonPlaybackToggleRequest
|
||||
) => void;
|
||||
readonly emitSeriesToggle: (
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watched-toggle state for the season header, extracted from
|
||||
* `SeasonContainerComponent` (which sits at the max-lines cap): the
|
||||
* season-scoped bulk toggle plus the series-scoped menu action.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonWatchPresenter {
|
||||
private readonly logger = createLogger('SeasonWatchPresenter');
|
||||
private readonly sources = signal<SeasonWatchPresenterSources | null>(
|
||||
null
|
||||
);
|
||||
|
||||
connect(sources: SeasonWatchPresenterSources): void {
|
||||
this.sources.set(sources);
|
||||
}
|
||||
|
||||
readonly selectedSeasonUnwatchedCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return sources
|
||||
.selectedSeasonEpisodes()
|
||||
.filter((episode) => !sources.isEpisodeWatched(episode)).length;
|
||||
});
|
||||
|
||||
readonly selectedSeasonFullyWatched = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!!sources &&
|
||||
sources.selectedSeasonEpisodes().length > 0 &&
|
||||
this.selectedSeasonUnwatchedCount() === 0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The episode currently playing (inline or externally) or launching is
|
||||
* never bulk-marked: the player persists its live position and would
|
||||
* immediately overwrite the full-progress row.
|
||||
*/
|
||||
private readonly watchExcludedIds = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return new Set<number>();
|
||||
}
|
||||
const ids = [
|
||||
sources.playingEpisodeId(),
|
||||
sources.activeEpisodeId(),
|
||||
sources.openingEpisodeId(),
|
||||
].filter((id): id is number => id !== null);
|
||||
return new Set(ids);
|
||||
});
|
||||
|
||||
/** Count shown in the season toggle label: episodes the action touches. */
|
||||
readonly seasonWatchEligibleCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return this.selectedSeasonFullyWatched()
|
||||
? sources.selectedSeasonEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
sources.selectedSeasonEpisodes(),
|
||||
sources.isEpisodeWatched,
|
||||
this.watchExcludedIds()
|
||||
).length;
|
||||
});
|
||||
|
||||
readonly seasonWatchToggleVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!!sources &&
|
||||
Object.keys(sources.seasons()).length > 0 &&
|
||||
sources.selectedSeasonEpisodes().length > 0 &&
|
||||
!!sources.playlistId()
|
||||
);
|
||||
});
|
||||
|
||||
toggleSeasonWatched(): void {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return;
|
||||
}
|
||||
const seasonKey = sources.selectedSeason();
|
||||
const playlistId = sources.playlistId();
|
||||
if (!seasonKey || !playlistId) {
|
||||
this.logger.warn(
|
||||
'Cannot toggle season watched: no season/playlist'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sources.batchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: sources.selectedSeasonEpisodes(),
|
||||
seasonKey,
|
||||
seriesId: sources.seriesId(),
|
||||
playlistId,
|
||||
isEpisodeWatched: sources.isEpisodeWatched,
|
||||
excludedEpisodeIds: this.watchExcludedIds(),
|
||||
});
|
||||
if (request) {
|
||||
sources.emitSeasonToggle(request);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly loadedEpisodes = computed(() => {
|
||||
const sources = this.sources();
|
||||
const episodes: XtreamSerieEpisode[] = [];
|
||||
if (!sources) {
|
||||
return episodes;
|
||||
}
|
||||
for (const seasonEpisodes of Object.values(sources.seasons())) {
|
||||
episodes.push(...(seasonEpisodes ?? []));
|
||||
}
|
||||
return episodes;
|
||||
});
|
||||
|
||||
/** False while unloaded seasons make any series-wide count a guess. */
|
||||
readonly seriesCountKnown = computed(
|
||||
() => !(this.sources()?.hasUnloadedSeasons() ?? false)
|
||||
);
|
||||
|
||||
/**
|
||||
* Only a series whose every season is loaded and watched flips the action
|
||||
* to unwatch-all; unloaded seasons keep the direction at "mark".
|
||||
*/
|
||||
readonly seriesFullyWatched = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources || sources.hasUnloadedSeasons()) {
|
||||
return false;
|
||||
}
|
||||
const episodes = this.loadedEpisodes();
|
||||
return (
|
||||
episodes.length > 0 &&
|
||||
episodes.every((episode) => sources.isEpisodeWatched(episode))
|
||||
);
|
||||
});
|
||||
|
||||
/** Count shown in the series action label: episodes the action touches. */
|
||||
readonly seriesWatchEligibleCount = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return 0;
|
||||
}
|
||||
return this.seriesFullyWatched()
|
||||
? this.loadedEpisodes().length
|
||||
: listMarkableEpisodes(
|
||||
this.loadedEpisodes(),
|
||||
sources.isEpisodeWatched,
|
||||
this.watchExcludedIds()
|
||||
).length;
|
||||
});
|
||||
|
||||
readonly seriesMenuVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (
|
||||
!sources ||
|
||||
!sources.playlistId() ||
|
||||
Object.keys(sources.seasons()).length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons()
|
||||
);
|
||||
});
|
||||
|
||||
readonly seriesActionDisabled = computed(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
sources.batchRunning() ||
|
||||
!(
|
||||
this.seriesFullyWatched() ||
|
||||
this.seriesWatchEligibleCount() > 0 ||
|
||||
sources.hasUnloadedSeasons()
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
toggleSeriesWatched(): void {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return;
|
||||
}
|
||||
const playlistId = sources.playlistId();
|
||||
if (!playlistId) {
|
||||
this.logger.warn('Cannot toggle series watched: no playlist');
|
||||
return;
|
||||
}
|
||||
if (sources.batchRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The direction the label advertised, never re-inferred at persist
|
||||
// time: with every loaded episode watched but seasons still unloaded
|
||||
// the label says "mark", while inference over loaded data would flip
|
||||
// to an unwatch.
|
||||
const markWatched = !this.seriesFullyWatched();
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: sources.seasons(),
|
||||
seriesId: sources.seriesId(),
|
||||
playlistId,
|
||||
isEpisodeWatched: sources.isEpisodeWatched,
|
||||
excludedEpisodeIds: this.watchExcludedIds(),
|
||||
markWatched,
|
||||
});
|
||||
if (request) {
|
||||
sources.emitSeriesToggle(request);
|
||||
return;
|
||||
}
|
||||
// No loaded target but unloaded seasons remain: emit an empty mark
|
||||
// request so the host can hydrate the missing seasons and rebuild.
|
||||
if (markWatched && sources.hasUnloadedSeasons()) {
|
||||
sources.emitSeriesToggle({ markWatched: true, requests: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildSeasonWatchToggleRequest,
|
||||
buildSeriesWatchToggleRequest,
|
||||
} from './season-watch-toggle.util';
|
||||
|
||||
// The Stalker-style '45 min' format parses to 2700 seconds.
|
||||
const PARSED_DURATION = 2700;
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 0,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
const watchedIds = (ids: number[]) => {
|
||||
const set = new Set(ids);
|
||||
return (episode: XtreamSerieEpisode) => set.has(Number(episode.id));
|
||||
};
|
||||
|
||||
describe('buildSeriesWatchToggleRequest', () => {
|
||||
const twoSeasons = (): Record<string, XtreamSerieEpisode[]> => ({
|
||||
'1': [
|
||||
createEpisode({ id: '101', episode_num: 1 }),
|
||||
createEpisode({ id: '102', episode_num: 2 }),
|
||||
],
|
||||
'2': [
|
||||
createEpisode({ id: '201', episode_num: 1 }),
|
||||
createEpisode({ id: '202', episode_num: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
it('marks unwatched episodes across every season with per-season numbers', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101]),
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(true);
|
||||
expect(request?.requests.map((item) => item.contentXtreamId)).toEqual([
|
||||
102, 201, 202,
|
||||
]);
|
||||
// Episodes without their own season fall back to the KEY of the
|
||||
// season they came from, not one shared fallback.
|
||||
expect(
|
||||
request?.requests.map((item) => item.nextPosition?.seasonNumber)
|
||||
).toEqual([1, 2, 2]);
|
||||
expect(request?.requests[0].nextPosition).toEqual(
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 102,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 20,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: PARSED_DURATION,
|
||||
durationSeconds: PARSED_DURATION,
|
||||
playlistId: 'playlist-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips excluded episodes when marking', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
excludedEpisodeIds: new Set([201]),
|
||||
});
|
||||
|
||||
expect(request?.requests.map((item) => item.contentXtreamId)).toEqual([
|
||||
101, 102, 202,
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears every episode of every season on unwatch, exclusions ignored', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101, 102, 201, 202]),
|
||||
excludedEpisodeIds: new Set([101]),
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(false);
|
||||
expect(request?.requests).toEqual([
|
||||
{ contentXtreamId: 101, nextPosition: null },
|
||||
{ contentXtreamId: 102, nextPosition: null },
|
||||
{ contentXtreamId: 201, nextPosition: null },
|
||||
{ contentXtreamId: 202, nextPosition: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when there is nothing to touch', () => {
|
||||
expect(
|
||||
buildSeriesWatchToggleRequest({
|
||||
seasons: {},
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSeriesWatchToggleRequest({
|
||||
seasons: { '1': [] },
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('honors a forced mark direction over inference', () => {
|
||||
// Everything watched: inference would unwatch, the forced direction
|
||||
// (what the label advertised) yields no targets instead.
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101, 102, 201, 202]),
|
||||
markWatched: true,
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it('honors a forced unwatch direction and still clears all episodes', () => {
|
||||
const request = buildSeriesWatchToggleRequest({
|
||||
seasons: twoSeasons(),
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([101]),
|
||||
markWatched: false,
|
||||
});
|
||||
|
||||
expect(request?.markWatched).toBe(false);
|
||||
expect(request?.requests).toHaveLength(4);
|
||||
expect(
|
||||
request?.requests.every((item) => item.nextPosition === null)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeasonWatchToggleRequest', () => {
|
||||
it('returns null for an empty season', () => {
|
||||
expect(
|
||||
buildSeasonWatchToggleRequest({
|
||||
episodes: [],
|
||||
seasonKey: '1',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to a one-second duration when nothing parses', () => {
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: [createEpisode({ id: '101', info: undefined })],
|
||||
seasonKey: '3',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
});
|
||||
|
||||
expect(request?.requests[0].nextPosition).toEqual(
|
||||
expect.objectContaining({
|
||||
positionSeconds: 1,
|
||||
durationSeconds: 1,
|
||||
seasonNumber: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an episode-level season over the fallback key', () => {
|
||||
const request = buildSeasonWatchToggleRequest({
|
||||
episodes: [createEpisode({ id: '101', season: 5 })],
|
||||
seasonKey: '3',
|
||||
seriesId: 20,
|
||||
playlistId: 'playlist-1',
|
||||
isEpisodeWatched: watchedIds([]),
|
||||
});
|
||||
|
||||
expect(request?.requests[0].nextPosition?.seasonNumber).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,16 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
export interface SeasonContainerSeriesPlaybackToggleRequest {
|
||||
markWatched: boolean;
|
||||
requests: SeasonContainerPlaybackToggleRequest[];
|
||||
}
|
||||
|
||||
export interface SeasonContainerSeasonPlaybackToggleRequest
|
||||
extends SeasonContainerSeriesPlaybackToggleRequest {
|
||||
seasonKey: string;
|
||||
}
|
||||
|
||||
export function resolveEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
@@ -112,3 +116,64 @@ export function buildSeasonWatchToggleRequest(options: {
|
||||
|
||||
return { seasonKey: options.seasonKey, markWatched, requests };
|
||||
}
|
||||
|
||||
/**
|
||||
* Series-level bulk toggle request across every LOADED season, with the same
|
||||
* mark/unmark semantics as the season builder (marking touches only unwatched
|
||||
* episodes minus the exclusions; unmarking clears every episode, exclusions
|
||||
* ignored). Each season's key is that season's duration fallback, so
|
||||
* cross-season position rows keep their own season numbers.
|
||||
*
|
||||
* `markWatched` forces the direction the UI advertised. Without it the
|
||||
* direction is inferred from the loaded data — wrong for a lazily loaded
|
||||
* series whose loaded episodes are all watched while unloaded seasons remain:
|
||||
* the label says "mark", inference would say "unmark". Hosts that rebuild a
|
||||
* request after hydrating such seasons must pass the direction captured at
|
||||
* click time.
|
||||
*/
|
||||
export function buildSeriesWatchToggleRequest(options: {
|
||||
seasons: Record<string, readonly XtreamSerieEpisode[]>;
|
||||
seriesId: number;
|
||||
playlistId: string;
|
||||
isEpisodeWatched: (episode: XtreamSerieEpisode) => boolean;
|
||||
excludedEpisodeIds?: ReadonlySet<number>;
|
||||
markWatched?: boolean;
|
||||
}): SeasonContainerSeriesPlaybackToggleRequest | null {
|
||||
const seasonEntries = Object.entries(options.seasons);
|
||||
const markWatched =
|
||||
options.markWatched ??
|
||||
seasonEntries.some(([, episodes]) =>
|
||||
(episodes ?? []).some(
|
||||
(episode) => !options.isEpisodeWatched(episode)
|
||||
)
|
||||
);
|
||||
|
||||
const requests: SeasonContainerPlaybackToggleRequest[] = [];
|
||||
for (const [seasonKey, episodes] of seasonEntries) {
|
||||
const targets = markWatched
|
||||
? listMarkableEpisodes(
|
||||
episodes ?? [],
|
||||
options.isEpisodeWatched,
|
||||
options.excludedEpisodeIds
|
||||
)
|
||||
: (episodes ?? []);
|
||||
for (const episode of targets) {
|
||||
requests.push({
|
||||
contentXtreamId: Number(episode.id),
|
||||
nextPosition: markWatched
|
||||
? buildWatchedEpisodePosition({
|
||||
episode,
|
||||
seriesId: options.seriesId,
|
||||
playlistId: options.playlistId,
|
||||
fallbackSeasonKey: seasonKey,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { markWatched, requests };
|
||||
}
|
||||
Reference in new issue
Block a user