From 7fc9380bfffae8be74b8808a4d36ad90b0c78ebe Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:03:14 +0200 Subject: [PATCH] feat(portals): mark a full season as watched in one click (#1447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(portals): mark a full season as watched in one click Series detail pages on both Xtream and Stalker portals get a season-level watched toggle next to "Download season": marking writes full-progress rows for the unwatched episodes only (real durations survive), a fully watched season flips the action to unwatch-all. Persistence goes through new batch IPC channels (DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with onConflictDoUpdate().run(); the PWA data source rewrites its localStorage blob once). Stalker deliberately bypasses the batch IPC and loops the existing position-mutation queue so legacy-row reconciliation still runs and the queue coalesces to a single reload; partial failures surface a dedicated snackbar. Also removes the dead toggleEpisodeWatched store method, splits season-container/serial-details-playback under the max-lines cap (season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and classifies *.spec-data.ts fixtures under the test max-lines ceiling (baseline shrinks by main.preload.spec-data.ts). Closes #1442 Co-Authored-By: Claude Fable 5 * fix(portals): guard stale season batches and split partial-unwatch feedback Review follow-up (Codex on #1447): - A season batch completing after the user navigated to another series or playlist no longer writes the old series' rows into the freshly reset position state (episode ids can collide across playlists); the Xtream host captures the playlist/series identity before awaiting and skips the rendered-state mutation when it changed. The DB write is unaffected — it carries its own playlistId. - A partially failed "mark season as unwatched" on Stalker now reports a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the watch-direction "marked" text; translated into all 18 locales. Co-Authored-By: Claude Fable 5 * fix(portals): exclude the playing episode from season marking and count partial saves Second review round (Codex on #1447): - The episode currently playing (inline or in an external session, or with a launch in flight) is excluded from a season's mark-watched batch: the player persists its live position every ~15 s and would immediately overwrite the just-written full-progress row. The button count reflects the exclusion and the action disables when nothing is markable. Unmarking still clears such an episode — the recreated in-progress row reflects live playback truthfully. - A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row saved and published, only legacy cleanup failed) now counts as a watched success instead of feeding false total-failure feedback. Co-Authored-By: Claude Fable 5 * fix(portals): gate stale season-batch snackbars on the originating page Third review round (Codex on #1447): a batch resolving after the user navigated away no longer shows its contextless success/error snackbar on the newly opened detail page — the same ownership check that guards the state mutation now guards the feedback too. Co-Authored-By: Claude Fable 5 * fix(portals): sync catalog progress badges after toggles and gate Stalker feedback Fourth review round (Codex on #1447): - Any Xtream watched toggle (single episode or season batch) now refreshes XtreamStore.loadAllPositions after persisting — the catalog reads series-progress badges from the store, which otherwise loads positions once per playlist, so returning from the detail kept stale badges. Skipped when the playlist changed mid-flight (the store then belongs to the other playlist; its own init reloads positions). - Stalker's season snackbars are gated on the captured playlist/series identity, matching the Xtream ownership guard — a batch draining after navigation no longer reports on the newly opened page. - Stalker season-toggle specs moved to stalker-series-view.season-watch .spec.ts with their own harness; both prior spec files sat at the 1200-line test ceiling. Co-Authored-By: Claude Fable 5 * docs: describe the season watched toggle in CLAUDE.md Fifth review round (Codex on #1447): the canonical Seasons entry in the VOD/Series detail section now covers the bulk toggle, its playing-episode exclusion, both persistence paths, catalog badge sync, and the stale-completion contract. Co-Authored-By: Claude Fable 5 * fix(portals): let only the latest positions load patch the Xtream store Sixth review round (Codex on #1447): loadAllPositions is now latest-load-wins — a fetch superseded while in flight (playlist switch before getAllPlaybackPositions resolves) no longer patches the singleton store with the previous playlist's position maps, which could leave the new catalog showing the old playlist's progress badges. Co-Authored-By: Claude Fable 5 * docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md Seventh review round (Codex on #1447): both canonical max-lines descriptions now list **/*.spec-data.ts among the test-ceiling globs so future agents neither treat these fixtures as production files nor remove the exemption unknowingly. Co-Authored-By: Claude Fable 5 * fix(portals): parse "N min" durations when marking episodes watched Eighth review round (Codex on #1447): Stalker VOD episodes report durations like "45 min", which parseDuration could not read — bulk (and single) mark-watched then persisted 1/1-second rows. The minute format now parses to seconds, matching what the removed legacy store method already handled. Co-Authored-By: Claude Fable 5 * fix(portals): parse compound hour durations and cover the toggle end-to-end Ninth review round (Codex on #1447): - parseDuration now reads the compound "1h 30min" form the Xtream fixtures emit (hour group optional, so "45 min" keeps working) — bulk-marked episodes no longer persist a minutes-only duration. - New Playwright coverage exercises the season toggle through the real UI on both portals: Xtream (category → series detail → mark → reload-persistence → unmark) and Stalker (embedded-series flow, mark → unmark with the item's actual episode count). Co-Authored-By: Claude Fable 5 * fix(portals): refresh Stalker catalog progress badges after watched toggles Tenth review round (Codex on #1447): the Stalker mirror of the Xtream catalog sync — StalkerCatalogFacadeService loads its position maps once per playlist and the runtime bridge only pushes external-player updates, so renderer-initiated toggles left grid badges stale. The series view now calls the facade's new ownership-checked refreshPositions after the season batch (including partial successes) and after single toggles; the reload is latest-load-wins like the Xtream store fix. Optional injection keeps collection-detail mounts outside the catalog working. Co-Authored-By: Claude Fable 5 * test(portals): cover the season toggle batch IPC end-to-end in Electron Eleventh review round (Codex on #1447): the new Electron E2E marks a season through the real UI, asserts the eight SQLite rows written by DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge, proves persistence with a full app relaunch (renderer and main process die, so state can only come from the database file), and clears again through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows. Co-Authored-By: Claude Fable 5 * fix(dashboard): keep watched rows out of the series resume target Twelfth review round (Codex on #1447): a watched position row — a natural finish or a manual/bulk "mark watched" marker — is a completion record, not resumable progress. Continue Watching no longer auto-plays such an episode at its end; the handoff stays detail-only and the series page's quick-start picks the first unwatched episode instead. Card progress bars and SxxEyy badges keep their current source. Co-Authored-By: Claude Fable 5 * fix(portals): fail closed on refresh reads and gate batch APIs by capability Thirteenth review round (Codex on #1447): - Position-cache refreshes now use a failure-propagating read (getAllPlaybackPositionsOrThrow through the Electron data source): a transient IPC failure rejects instead of masquerading as an empty list, so a populated store/facade cache stays stale-but-populated rather than being wiped. All load/refresh call sites handle the new rejection (init loads may retry on the next activation; post-toggle refreshes log and keep the snackbar flow). - The season-batch bridge methods joined playbackPositionStorageMethods, so a bridge lacking them degrades to the in-memory path wholesale instead of throwing mid-action. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/portals-season-watched-toggle.md | 10 + AGENTS.md | 2 +- CLAUDE.md | 16 +- .../src/season-watched-toggle.e2e.ts | 264 ++++++++++++ .../src/app/api/main.preload.spec-data.ts | 15 + .../src/app/api/main.preload.ts | 18 + .../playback-position.operations.spec.ts | 225 ++++++++++ .../playback-position.operations.ts | 90 ++++ .../database/playback-position.events.ts | 19 + .../database/worker-ipc-contract.spec-data.ts | 22 + .../src/app/workers/database-worker.types.ts | 2 + .../src/app/workers/database.worker.ts | 43 ++ apps/web-e2e/src/stalker.e2e.ts | 72 ++++ apps/web-e2e/src/xtream.e2e.ts | 105 +++++ .../portal-playback-positions.service.ts | 14 + apps/web/src/assets/i18n/ar.json | 7 + apps/web/src/assets/i18n/ary.json | 7 + apps/web/src/assets/i18n/by.json | 7 + apps/web/src/assets/i18n/de.json | 7 + apps/web/src/assets/i18n/el.json | 7 + apps/web/src/assets/i18n/en.json | 7 + apps/web/src/assets/i18n/es.json | 7 + apps/web/src/assets/i18n/fr.json | 7 + apps/web/src/assets/i18n/hu.json | 7 + apps/web/src/assets/i18n/it.json | 7 + apps/web/src/assets/i18n/ja.json | 7 + apps/web/src/assets/i18n/ko.json | 7 + apps/web/src/assets/i18n/nl.json | 7 + apps/web/src/assets/i18n/pl.json | 7 + apps/web/src/assets/i18n/pt.json | 7 + apps/web/src/assets/i18n/ru.json | 7 + apps/web/src/assets/i18n/tr.json | 7 + apps/web/src/assets/i18n/zh.json | 7 + apps/web/src/assets/i18n/zhtw.json | 7 + docs/architecture/embedded-inline-playback.md | 25 ++ .../util/src/lib/portal-playback-positions.ts | 13 + .../src/lib/stalker-catalog-facade.service.ts | 26 +- .../stalker-series-view.component.html | 4 + .../stalker-series-view.component.spec.ts | 2 + .../stalker-series-view.component.ts | 137 +++++- ...series-view.position-compatibility.spec.ts | 31 +- .../stalker-series-view.season-watch.spec.ts | 400 ++++++++++++++++++ ...lectron-xtream-data-source.test-helpers.ts | 3 + .../electron-xtream-data-source.ts | 24 +- ...ctron-xtream-data-source.user-data.spec.ts | 38 +- .../pwa-xtream-data-source.spec.ts | 151 +++++++ .../data-sources/pwa-xtream-data-source.ts | 53 +++ .../xtream-data-source.interface.ts | 18 + .../with-playback-positions.feature.spec.ts | 102 +++++ .../with-playback-positions.feature.ts | 140 +----- ...al-details-playback-position-state.spec.ts | 36 ++ .../serial-details-playback-position-state.ts | 22 + .../serial-details-playback.service.ts | 62 ++- .../serial-details-season-watch.service.ts | 98 +++++ .../serial-details.component.html | 4 + .../serial-details.component.spec.ts | 191 +++++++++ .../serial-details.component.ts | 11 +- .../lib/xtream-catalog-facade.service.spec.ts | 2 +- .../src/lib/xtream-catalog-facade.service.ts | 6 +- ...ck-position-runtime-bridge.service.spec.ts | 119 ++++++ ...layback-position-runtime-bridge.service.ts | 62 +++ .../src/lib/playback-position.service.spec.ts | 52 +++ .../src/lib/playback-position.service.ts | 34 ++ .../lib/runtime-capabilities.service.spec.ts | 13 + .../src/lib/runtime-capabilities.service.ts | 5 + .../src/lib/electron-api.interface.ts | 11 + .../season-container/episode-progress.util.ts | 10 + .../season-container/episode-utils.spec.ts | 5 + .../season-container.component.html | 5 + .../season-container.component.spec.ts | 188 ++++++++ .../season-container.component.ts | 119 ++++-- .../season-header.component.html | 40 +- .../season-header.component.scss | 3 +- .../season-header.component.ts | 5 + .../season-watch-toggle.util.ts | 114 +++++ .../src/lib/dashboard-navigation.util.spec.ts | 68 +++ .../src/lib/dashboard-navigation.util.ts | 9 + tools/eslint/max-lines-baseline.mjs | 1 - tools/eslint/max-lines-config.mjs | 4 + 79 files changed, 3317 insertions(+), 199 deletions(-) create mode 100644 .changes/portals-season-watched-toggle.md create mode 100644 apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-season-watch.service.ts create mode 100644 libs/ui/components/src/lib/season-container/season-watch-toggle.util.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts diff --git a/.changes/portals-season-watched-toggle.md b/.changes/portals-season-watched-toggle.md new file mode 100644 index 000000000..5bdbe6387 --- /dev/null +++ b/.changes/portals-season-watched-toggle.md @@ -0,0 +1,10 @@ +--- +type: feature +area: portals +issues: [1442] +--- + +Series detail pages can now mark a whole season as watched (or unwatched) in +one click — the new button next to "Download season" works for both Xtream and +Stalker portals. Handy when you start a show mid-way: mark the earlier seasons +watched and the Play button jumps straight to your next unwatched episode. diff --git a/AGENTS.md b/AGENTS.md index 2da266468..bce27e7e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ This file provides guidance to coding agents working in this repository. lockfile, run generated migrations when present, and validate before opening a PR. Major updates are always manual. Replace incomplete Dependabot security PRs with a coordinated update instead of editing the bot branch. -- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. +- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills live under `.codex/skills/`. - Frontmatter descriptions are trigger-only and begin with `Use when`; keep diff --git a/CLAUDE.md b/CLAUDE.md index d3c44cd5e..4d2f5377a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,10 +252,13 @@ baseline generator import so the enforced rule and the generated list cannot drift: - **Production TypeScript: hard maximum 400 lines.** -- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under - `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one - at the production limit yields arbitrary `-2.spec.ts` files, and length there - signals coverage rather than the design debt the production limit catches. +- **Tests: 1200.** `**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts` and + everything under `apps/*-e2e/**` — a spec is a flat list of independent + cases, so splitting one at the production limit yields arbitrary + `-2.spec.ts` files, and length there signals coverage rather than the + design debt the production limit catches. `.spec-data.ts` fixtures (flat + case lists consumed only by a spec, e.g. the worker IPC contract table) + grow with coverage the same way. - **Blank lines and comments are not counted** (`skipBlankLines`, `skipComments`), so a docblock is never the reason a file must be split. @@ -575,8 +578,8 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. Keep production TypeScript files under **300 lines**. Hard maximum is **350–400 lines**, and CI enforces the 400. Blank lines and comments do not count toward it, so documenting a file never costs you headroom. Tests -(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the -guidance below is about production code. +(`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held +to 1200 instead — the guidance below is about production code. - When creating new files, design them to stay within this limit from the start. - When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code. @@ -1303,6 +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` - 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") diff --git a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts new file mode 100644 index 000000000..9ceffa05c --- /dev/null +++ b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts @@ -0,0 +1,264 @@ +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + clickGridListCardByTitle, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + launchElectronApp, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForXtreamImportToFinish, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { fetchXtreamSeriesFixture } from './portal-mock-fixtures'; + +// --------------------------------------------------------------------------- +// Season-level watched toggle on the Electron serial details page +// +// The season header exposes a bulk toggle carrying `data-test-id` (this +// suite's testIdAttribute, so getByTestId matches it), while the per-episode +// toggles carry `data-testid` and need raw attribute locators. In Electron +// the toggle routes every unwatched episode of the selected season through +// the DB_SAVE_PLAYBACK_POSITIONS_BATCH IPC (preload → main → database worker +// → one SQLite transaction) and the unmark path through +// DB_CLEAR_PLAYBACK_POSITIONS_BATCH. The runtime bridge has no per-episode +// fallback for these calls (`PlaybackPositionRuntimeBridgeService` throws +// when the batch methods are missing), so the rows asserted below via +// `dbGetAllPlaybackPositions` prove the batch IPC executed end to end. +// Durability is proven with a full app relaunch: the renderer and the main +// process restart, so the watched state can only come from SQLite on disk. +// --------------------------------------------------------------------------- + +// The default `user1:pass1` mock scenario generates 3 seasons × 8 episodes +// per series (season/episode numbers starting at 1), matching the PWA spec. +const seasonCount = 3; +const seasonEpisodeCount = 8; +const expectedEpisodeNumbers = [1, 2, 3, 4, 5, 6, 7, 8]; +const portalName = 'Season Watched Toggle'; + +const watchedEpisodeToggleSelector = + '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'; + +type EpisodePositionRow = { + episodeNumber: number | null; + positionSeconds: number; + seasonNumber: number | null; +}; + +/** Reads the persisted episode positions straight from SQLite over IPC. */ +async function readEpisodePositions( + page: Page, + playlistId: string +): Promise { + return page.evaluate(async (id) => { + const positions = + (await window.electron?.dbGetAllPlaybackPositions?.(id)) ?? []; + return positions + .filter((position) => position.contentType === 'episode') + .map((position) => ({ + episodeNumber: position.episodeNumber ?? null, + positionSeconds: position.positionSeconds, + seasonNumber: position.seasonNumber ?? null, + })); + }, playlistId); +} + +function extractPlaylistId(page: Page): string { + const match = new URL(page.url()).pathname.match( + /\/workspace\/xtreams\/([^/]+)\// + ); + if (!match) { + throw new Error(`Expected an Xtream route, got ${page.url()}`); + } + return match[1]; +} + +/** + * Opens the given category's series grid and clicks into a series detail. + * Without `seriesTitle` it clicks the first card (the grid sorts by date, so + * fixture order is not grid order) and returns the clicked title so the + * post-restart navigation can target the exact same series. + */ +async function openSeriesDetail( + page: Page, + categoryName: string, + seriesTitle?: string +): Promise { + await openWorkspaceSection(page, 'Series'); + await clickCategoryByNameExact(page, categoryName); + + let clickedTitle: string; + if (seriesTitle === undefined) { + clickedTitle = await clickFirstGridListCard(page); + } else { + await clickGridListCardByTitle(page, seriesTitle); + clickedTitle = seriesTitle; + } + + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); + return clickedTitle; +} + +test.describe('Electron Season Watched Toggle', () => { + test('@xtream @persistence @electron marks a season watched via the batch IPC, survives an app restart, and clears again', async ({ + dataDir, + request, + }) => { + // Full app relaunch mid-test: startup alone can eat half the default + // 60s budget on a real Electron app. + test.setTimeout(120_000); + + await resetMockServers(request, ['xtream']); + const seriesFixture = await fetchXtreamSeriesFixture(request, { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { name: portalName }); + await waitForXtreamImportToFinish(app.mainWindow); + + const seriesTitle = await openSeriesDetail( + app.mainWindow, + seriesFixture.categoryName + ); + const playlistId = extractPlaylistId(app.mainWindow); + + // Season 1 auto-selects fully unwatched: the toggle offers to mark + // all 8 episodes and neither the DOM nor SQLite knows any watched + // episode yet. + const seasonToggle = app.mainWindow.getByTestId( + 'toggle-season-watched' + ); + await expect(seasonToggle).toBeVisible({ timeout: 20_000 }); + await expect(seasonToggle).toContainText( + `Mark season as watched (${seasonEpisodeCount})` + ); + + const episodeCards = app.mainWindow.locator('.episode-card'); + const watchedCards = app.mainWindow.locator( + '.episode-card--watched' + ); + await expect(episodeCards).toHaveCount(seasonEpisodeCount, { + timeout: 10_000, + }); + await expect(watchedCards).toHaveCount(0); + expect( + await readEpisodePositions(app.mainWindow, playlistId) + ).toEqual([]); + + await seasonToggle.click(); + + // The batch save flips the button, marks every card, and fills + // the per-episode toggles; the untouched second season stays + // unmarked while the selected season's tab shows the check. + await expect(seasonToggle).toContainText( + 'Mark season as unwatched', + { timeout: 15_000 } + ); + await expect(watchedCards).toHaveCount(seasonEpisodeCount); + await expect( + app.mainWindow.locator(watchedEpisodeToggleSelector) + ).toHaveCount(seasonEpisodeCount); + + const seasonTabs = app.mainWindow.locator('.season-tabs__pill'); + await expect(seasonTabs).toHaveCount(seasonCount); + await expect( + seasonTabs.first().locator('.season-tabs__done') + ).toBeVisible(); + await expect( + seasonTabs.nth(1).locator('.season-tabs__done') + ).toHaveCount(0); + + // The single batch transaction landed 8 full-progress episode + // rows for season 1 in SQLite. + await expect + .poll(() => readEpisodePositions(app.mainWindow, playlistId), { + timeout: 20_000, + }) + .toHaveLength(seasonEpisodeCount); + const savedRows = await readEpisodePositions( + app.mainWindow, + playlistId + ); + expect( + savedRows + .map((row) => row.episodeNumber) + .sort((left, right) => (left ?? 0) - (right ?? 0)) + ).toEqual(expectedEpisodeNumbers); + expect( + savedRows.every( + (row) => row.seasonNumber === 1 && row.positionSeconds > 0 + ) + ).toBe(true); + + // Relaunch the whole app: the watched state must be re-read from + // the SQLite database file, not from any renderer memory. + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openSeriesDetail( + app.mainWindow, + seriesFixture.categoryName, + seriesTitle + ); + + const restartedToggle = app.mainWindow.getByTestId( + 'toggle-season-watched' + ); + const restartedWatchedCards = app.mainWindow.locator( + '.episode-card--watched' + ); + await expect(restartedToggle).toBeVisible({ timeout: 20_000 }); + await expect(restartedToggle).toContainText( + 'Mark season as unwatched' + ); + await expect(restartedWatchedCards).toHaveCount( + seasonEpisodeCount, + { timeout: 10_000 } + ); + await expect( + app.mainWindow + .locator('.season-tabs__pill') + .first() + .locator('.season-tabs__done') + ).toBeVisible(); + + // Unmark: the clear batch removes every row again, in the DOM and + // in SQLite. + await restartedToggle.click(); + await expect(restartedToggle).toContainText( + `Mark season as watched (${seasonEpisodeCount})`, + { timeout: 15_000 } + ); + await expect(restartedWatchedCards).toHaveCount(0); + await expect( + app.mainWindow.locator(watchedEpisodeToggleSelector) + ).toHaveCount(0); + await expect( + app.mainWindow.locator('.season-tabs__done') + ).toHaveCount(0); + await expect + .poll(() => readEpisodePositions(app.mainWindow, playlistId), { + timeout: 20_000, + }) + .toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 7b1a57352..16429250b 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -376,6 +376,21 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_CLEAR_PLAYBACK_POSITION', forwardedArgs: [playlistId, 42, 'vod'], }, + { + method: 'dbSavePlaybackPositionsBatch', + args: [playlistId, [playbackData]], + channel: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', + forwardedArgs: [playlistId, [playbackData]], + }, + { + method: 'dbClearPlaybackPositionsBatch', + args: [playlistId, [{ contentXtreamId: 42, contentType: 'episode' }]], + channel: 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', + forwardedArgs: [ + playlistId, + [{ contentXtreamId: 42, contentType: 'episode' }], + ], + }, { method: 'dbGetTmdbMetadata', args: ['movie', 'id:603', 'en-US'], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6cc03004c..260d8f71b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1005,6 +1005,24 @@ const electronApi: ElectronBridgeApi = { contentXtreamId, contentType ), + dbSavePlaybackPositionsBatch: ( + playlistId: string, + items: ElectronBridgePlaybackPositionInput[] + ) => + ipcRenderer.invoke( + 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', + playlistId, + items + ), + dbClearPlaybackPositionsBatch: ( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ) => + ipcRenderer.invoke( + 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', + playlistId, + items + ), getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), // Downloads downloadsStart: (data: ElectronBridgeDownloadStartPayload) => diff --git a/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts index 1a919641d..59dc32d02 100644 --- a/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts @@ -11,13 +11,31 @@ import * as schema from '@iptvnator/shared/database/schema'; import { clearAllPlaybackPositions, clearPlaybackPosition, + clearPlaybackPositionsBatch, getAllPlaybackPositions, getPlaybackPosition, getRecentPlaybackPositions, getSeriesPlaybackPositions, savePlaybackPosition, + savePlaybackPositionsBatch, } from './playback-position.operations'; +/** + * Batch saves upsert inside a synchronous transaction, so the insert chain + * has to end in a synchronous `.run()` rather than resolving — exactly as + * the better-sqlite3 driver requires (issue #1137). The wrapper exposes + * both dispatch methods so the specs can assert the correct one is used. + */ +function createBatchUpsertDbMock(selectResultsByCall: unknown[][] = []) { + const mock = createDbMock(selectResultsByCall); + const insertExecute = jest.fn().mockResolvedValue(undefined); + const onConflictDoUpdate = jest + .fn() + .mockReturnValue({ run: mock.insertRun, execute: insertExecute }); + mock.insertValues.mockReturnValue({ onConflictDoUpdate }); + return { ...mock, insertExecute, onConflictDoUpdate }; +} + describe('playback-position.operations', () => { beforeEach(() => { resetDrizzleMocks(); @@ -138,6 +156,153 @@ describe('playback-position.operations', () => { }); }); + describe('savePlaybackPositionsBatch', () => { + it('returns a zero count without touching the database for empty input', async () => { + const { db, select, insert, transaction } = createDbMock(); + + await expect( + savePlaybackPositionsBatch(db, 'playlist-1', []) + ).resolves.toEqual({ success: true, count: 0 }); + + expect(select).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('upserts every item inside one transaction with the composite conflict target', async () => { + const { + db, + select, + insert, + insertValues, + onConflictDoUpdate, + insertRun, + insertExecute, + transaction, + } = createBatchUpsertDbMock([[{ id: 'playlist-1' }]]); + + const result = await savePlaybackPositionsBatch(db, 'playlist-1', [ + { + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + }, + { + contentXtreamId: 42, + contentType: 'episode', + seriesXtreamId: 7, + seasonNumber: 2, + episodeNumber: 5, + positionSeconds: 480, + }, + ]); + + expect(result).toEqual({ success: true, count: 2 }); + // The playlist existence check runs exactly once for the whole + // batch, and it must complete before the synchronous transaction + // callback starts. + expect(select).toHaveBeenCalledTimes(1); + expect(select.mock.invocationCallOrder[0]).toBeLessThan( + transaction.mock.invocationCallOrder[0] + ); + expect(transaction).toHaveBeenCalledTimes(1); + expect(insert).toHaveBeenCalledTimes(2); + expect(insert).toHaveBeenCalledWith(schema.playbackPositions); + expect(insertValues).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + playlistId: 'playlist-1', + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + updatedAt: expect.objectContaining({ kind: 'sql' }), + }) + ); + expect(insertValues).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'episode', + seriesXtreamId: 7, + seasonNumber: 2, + episodeNumber: 5, + positionSeconds: 480, + }) + ); + expect(onConflictDoUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: [ + schema.playbackPositions.contentXtreamId, + schema.playbackPositions.playlistId, + schema.playbackPositions.contentType, + ], + set: expect.objectContaining({ + positionSeconds: 120, + durationSeconds: 3600, + updatedAt: expect.objectContaining({ kind: 'sql' }), + }), + }) + ); + expect(onConflictDoUpdate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + set: expect.objectContaining({ + seriesXtreamId: 7, + seasonNumber: 2, + episodeNumber: 5, + positionSeconds: 480, + }), + }) + ); + // Regression (issue #1137): the upsert must be dispatched with + // synchronous `.run()`. `.execute()` defers to a promise that + // never settles inside the synchronous transaction callback, so + // the batch write would silently no-op. + expect(insertExecute).not.toHaveBeenCalled(); + expect(insertRun).toHaveBeenCalledTimes(2); + }); + + it('creates the missing playlist once, honoring the first item playlist type', async () => { + const { db, insert, insertValues } = createBatchUpsertDbMock([[]]); + + await savePlaybackPositionsBatch(db, 'playlist-xt', [ + { + contentXtreamId: 1, + contentType: 'vod', + positionSeconds: 10, + playlistType: 'xtream', + }, + { + contentXtreamId: 2, + contentType: 'vod', + positionSeconds: 20, + }, + ]); + + // One playlist insert despite two items, then one position + // upsert per item. + expect(insert).toHaveBeenCalledTimes(3); + expect(insert).toHaveBeenNthCalledWith(1, schema.playlists); + expect(insertValues).toHaveBeenNthCalledWith(1, { + id: 'playlist-xt', + name: 'Imported Playlist', + type: 'xtream', + }); + expect(insert).toHaveBeenNthCalledWith( + 2, + schema.playbackPositions + ); + expect(insert).toHaveBeenNthCalledWith( + 3, + schema.playbackPositions + ); + }); + }); + describe('getPlaybackPosition', () => { it('returns the matching row scoped by playlist, content, and type', async () => { const row = { @@ -275,4 +440,64 @@ describe('playback-position.operations', () => { ); }); }); + + describe('clearPlaybackPositionsBatch', () => { + it('returns a zero count without touching the database for empty input', async () => { + const { db, deleteFn, transaction } = createDbMock(); + + await expect( + clearPlaybackPositionsBatch(db, 'playlist-1', []) + ).resolves.toEqual({ success: true, count: 0 }); + + expect(deleteFn).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('runs one prepared placeholder delete per item inside a transaction', async () => { + const { + db, + deleteFn, + deletePrepare, + deleteRun, + deleteExecute, + transaction, + } = createDbMock(); + + await expect( + clearPlaybackPositionsBatch(db, 'playlist-1', [ + { contentXtreamId: 500, contentType: 'vod' }, + { contentXtreamId: 42, contentType: 'episode' }, + ]) + ).resolves.toEqual({ success: true, count: 2 }); + + expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions); + expect(deletePrepare).toHaveBeenCalledTimes(1); + // The playlist id is bound directly; content id and type are + // per-item placeholders. + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.playlistId, + 'playlist-1' + ); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith( + 'contentXtreamId' + ); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith( + 'contentType' + ); + expect(transaction).toHaveBeenCalledTimes(1); + // Regression (issue #1137): the prepared delete must be + // dispatched with synchronous `.run()`. `.execute()` defers to a + // promise that never settles inside the synchronous transaction + // callback, so the batch delete would silently no-op. + expect(deleteExecute).not.toHaveBeenCalled(); + expect(deleteRun).toHaveBeenNthCalledWith(1, { + contentXtreamId: 500, + contentType: 'vod', + }); + expect(deleteRun).toHaveBeenNthCalledWith(2, { + contentXtreamId: 42, + contentType: 'episode', + }); + }); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/playback-position.operations.ts b/apps/electron-backend/src/app/database/operations/playback-position.operations.ts index e7782139f..6bb20052e 100644 --- a/apps/electron-backend/src/app/database/operations/playback-position.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.ts @@ -81,6 +81,96 @@ export async function savePlaybackPosition( return { success: true }; } +export async function savePlaybackPositionsBatch( + db: AppDatabase, + playlistId: string, + items: PlaybackPositionPayload[] +): Promise<{ success: boolean; count: number }> { + if (!Array.isArray(items) || items.length === 0) { + return { success: true, count: 0 }; + } + + // ensurePlaylistExists awaits, so it must run before the synchronous + // transaction callback below. + await ensurePlaylistExists(db, playlistId, items[0]?.playlistType); + + await db.transaction(() => { + for (const item of items) { + // .run() (synchronous), NOT .execute(): the better-sqlite3 + // driver's .execute() defers the write to a resolved promise + // that never settles inside this synchronous transaction + // callback, so the upsert would silently do nothing. See the + // matching note in recently-viewed.operations.ts (issue #1137). + db.insert(schema.playbackPositions) + .values({ + playlistId, + contentXtreamId: item.contentXtreamId, + contentType: item.contentType, + seriesXtreamId: item.seriesXtreamId, + seasonNumber: item.seasonNumber, + episodeNumber: item.episodeNumber, + positionSeconds: item.positionSeconds, + durationSeconds: item.durationSeconds, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .onConflictDoUpdate({ + target: [ + schema.playbackPositions.contentXtreamId, + schema.playbackPositions.playlistId, + schema.playbackPositions.contentType, + ], + set: { + seriesXtreamId: item.seriesXtreamId, + seasonNumber: item.seasonNumber, + episodeNumber: item.episodeNumber, + positionSeconds: item.positionSeconds, + durationSeconds: item.durationSeconds, + updatedAt: sql`CURRENT_TIMESTAMP`, + }, + }) + .run(); + } + }); + + return { success: true, count: items.length }; +} + +export async function clearPlaybackPositionsBatch( + db: AppDatabase, + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] +): Promise<{ success: boolean; count: number }> { + if (!Array.isArray(items) || items.length === 0) { + return { success: true, count: 0 }; + } + + const stmt = db + .delete(schema.playbackPositions) + .where( + and( + eq(schema.playbackPositions.playlistId, playlistId), + eq( + schema.playbackPositions.contentXtreamId, + sql.placeholder('contentXtreamId') + ), + eq( + schema.playbackPositions.contentType, + sql.placeholder('contentType') + ) + ) + ) + .prepare(); + + await db.transaction(() => { + for (const { contentXtreamId, contentType } of items) { + // .run(), not .execute() — see savePlaybackPositionsBatch. + stmt.run({ contentXtreamId, contentType }); + } + }); + + return { success: true, count: items.length }; +} + export async function getPlaybackPosition( db: AppDatabase, playlistId: string, diff --git a/apps/electron-backend/src/app/events/database/playback-position.events.ts b/apps/electron-backend/src/app/events/database/playback-position.events.ts index ae007c9d6..04b4cf5cb 100644 --- a/apps/electron-backend/src/app/events/database/playback-position.events.ts +++ b/apps/electron-backend/src/app/events/database/playback-position.events.ts @@ -65,3 +65,22 @@ handleWorkerRequest( contentType, }) ); + +handleWorkerRequest( + 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', + (playlistId: string, items: unknown[]) => ({ + playlistId, + items, + }) +); + +handleWorkerRequest( + 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', + ( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ) => ({ + playlistId, + items, + }) +); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index d1d82c11c..00ae5f697 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -30,6 +30,18 @@ const playbackData = { contentType: 'vod', positionSeconds: 120, }; +const playbackBatchItems = [ + { + contentXtreamId: 42, + contentType: 'episode', + seriesXtreamId: 88, + seasonNumber: 1, + episodeNumber: 3, + positionSeconds: 1200, + durationSeconds: 1200, + }, +]; +const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }]; const tmdbCacheEntry = { mediaType: 'movie', lookupKey: 'id:603', @@ -330,6 +342,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [playlistId, 42, 'vod'], payload: { playlistId, contentXtreamId: 42, contentType: 'vod' }, }, + { + operation: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', + args: [playlistId, playbackBatchItems], + payload: { playlistId, items: playbackBatchItems }, + }, + { + operation: 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', + args: [playlistId, playbackClearBatchItems], + payload: { playlistId, items: playbackClearBatchItems }, + }, { operation: 'DB_GET_TMDB_METADATA', args: ['movie', 'id:603', 'en-US'], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 4dceab348..273376835 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -51,6 +51,8 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_PLAYBACK_POSITION', + 'DB_SAVE_PLAYBACK_POSITIONS_BATCH', + 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH', 'DB_GET_TMDB_METADATA', 'DB_SET_TMDB_METADATA', 'DB_GET_TMDB_CACHE_STATS', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 77346f858..34840c0e8 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -49,11 +49,13 @@ import { setContentMetadataIfMissing } from '../database/operations/content-meta import { clearAllPlaybackPositions, clearPlaybackPosition, + clearPlaybackPositionsBatch, getAllPlaybackPositions, getPlaybackPosition, getRecentPlaybackPositions, getSeriesPlaybackPositions, savePlaybackPosition, + savePlaybackPositionsBatch, } from '../database/operations/playback-position.operations'; import { createPlaylist, @@ -1139,6 +1141,47 @@ async function executeRequest( payload.contentType ); } + + case 'DB_SAVE_PLAYBACK_POSITIONS_BATCH': { + const payload = message.payload as { + playlistId: string; + items: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + positionSeconds: number; + durationSeconds?: number; + playlistType?: + | 'xtream' + | 'stalker' + | 'm3u-file' + | 'm3u-text' + | 'm3u-url'; + }[]; + }; + return savePlaybackPositionsBatch( + db, + payload.playlistId, + payload.items + ); + } + + case 'DB_CLEAR_PLAYBACK_POSITIONS_BATCH': { + const payload = message.payload as { + playlistId: string; + items: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + }[]; + }; + return clearPlaybackPositionsBatch( + db, + payload.playlistId, + payload.items + ); + } } } diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 763da1b1d..b1abc9288 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -952,6 +952,78 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e ).toBeVisible({ timeout: 10_000 }); }); +test('@stalker season watched toggle — embedded series marks and clears every episode', async ({ + page, + request, +}) => { + // Reuse the modeled embedded-series flow: find a VOD item carrying an + // embedded series[] array, open it from its category, and land on the + // series detail with its episode list. + 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 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 season header's bulk toggle (data-test-id with a dash — getByTestId + // only matches data-testid in this suite) counts every unwatched episode. + const seasonToggle = page.locator( + '[data-test-id="toggle-season-watched"]' + ); + await expect(seasonToggle).toBeVisible(); + await expect(seasonToggle).toContainText( + `Mark season as watched (${episodeCount})` + ); + const watchedCards = page.locator('.episode-card--watched'); + await expect(watchedCards).toHaveCount(0); + + await seasonToggle.click(); + + // All episodes get full-progress positions: the button flips and every + // episode card plus per-episode toggle shows the watched state. + await expect(seasonToggle).toContainText('Mark season as unwatched', { + timeout: 15_000, + }); + await expect(watchedCards).toHaveCount(episodeCount); + await expect( + page.locator( + '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched' + ) + ).toHaveCount(episodeCount); + + // Second click clears every episode's position again. + await seasonToggle.click(); + await expect(seasonToggle).toContainText( + `Mark season as watched (${episodeCount})`, + { timeout: 15_000 } + ); + await expect(watchedCards).toHaveCount(0); +}); + test('@stalker series — seasons load for a series item', async ({ request, }) => { diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 163f5fc47..473fddf61 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -667,6 +667,111 @@ test('@xtream the saved engine mounts the live player first time — no default- ).toEqual(['app-html-video-player']); }); +// --------------------------------------------------------------------------- +// Season-level watched toggle on the serial details page +// +// The season header exposes a bulk toggle (note: data-test-id with a dash — +// getByTestId only matches data-testid in this suite) that writes +// full-progress playback positions for every unwatched episode of the +// selected season and clears them again on the second click. The PWA +// persists positions to localStorage, so the state must survive a reload. +// --------------------------------------------------------------------------- + +test('@xtream season watched toggle — marks a season, survives reload, and clears again', async ({ + page, + request, +}) => { + // Resolve a concrete series (category + name) from the mock so the card + // click below targets a known item of the default scenario. + 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(); + + // Serial details: default scenario has 3 seasons × 8 episodes and no + // playback positions yet, so season 1 is auto-selected fully unwatched. + const seasonToggle = page.locator( + '[data-test-id="toggle-season-watched"]' + ); + await expect(seasonToggle).toBeVisible({ timeout: 15_000 }); + await expect(seasonToggle).toContainText('Mark season as watched (8)'); + + const episodeCards = page.locator('.episode-card'); + await expect(episodeCards).toHaveCount(8, { timeout: 10_000 }); + const watchedCards = page.locator('.episode-card--watched'); + await expect(watchedCards).toHaveCount(0); + + await seasonToggle.click(); + + // Full-progress rows land for all 8 episodes: the button flips, every + // episode card gets the watched state, and the per-episode toggles show + // the filled check. + await expect(seasonToggle).toContainText('Mark season as unwatched', { + timeout: 15_000, + }); + await expect(watchedCards).toHaveCount(8); + await expect( + page.locator( + '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched' + ) + ).toHaveCount(8); + + // The selected season's tab shows the completed check; the untouched + // seasons stay unmarked. + const seasonTabs = page.locator('.season-tabs__pill'); + await expect(seasonTabs).toHaveCount(3); + await expect( + seasonTabs.first().locator('.season-tabs__done') + ).toBeVisible(); + await expect(seasonTabs.nth(1).locator('.season-tabs__done')).toHaveCount( + 0 + ); + + // PWA persistence: positions live in localStorage, so a reload of the + // detail route must come back fully watched. + await page.reload(); + await expect(seasonToggle).toBeVisible({ timeout: 20_000 }); + await expect(seasonToggle).toContainText('Mark season as unwatched'); + await expect(watchedCards).toHaveCount(8, { timeout: 10_000 }); + await expect( + seasonTabs.first().locator('.season-tabs__done') + ).toBeVisible(); + + // Second click clears every episode's position again. + await seasonToggle.click(); + await expect(seasonToggle).toContainText('Mark season as watched (8)', { + timeout: 15_000, + }); + await expect(watchedCards).toHaveCount(0); + await expect(page.locator('.season-tabs__done')).toHaveCount(0); +}); + type XtreamLiveStream = { category_id: string; name: string; diff --git a/apps/web/src/app/services/portal-playback-positions.service.ts b/apps/web/src/app/services/portal-playback-positions.service.ts index cb6db69ed..bcc29b73b 100644 --- a/apps/web/src/app/services/portal-playback-positions.service.ts +++ b/apps/web/src/app/services/portal-playback-positions.service.ts @@ -108,6 +108,20 @@ export class AppPortalPlaybackPositionsService contentType ); } + + async savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + await this.dataSource.savePlaybackPositionsBatch(playlistId, items); + } + + async clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise { + await this.dataSource.clearPlaybackPositionsBatch(playlistId, items); + } } export const providePortalPlaybackPositions = () => [ diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 55dfd402d..f09a89773 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "قيد التقدم", "MARK_WATCHED": "وضع علامة كمشاهد", "MARK_UNWATCHED": "وضع علامة كغير مشاهد", + "MARK_SEASON_WATCHED": "وضع علامة على الموسم كمشاهد ({{count}})", + "MARK_SEASON_UNWATCHED": "وضع علامة على الموسم كغير مشاهد", + "SEASON_MARKED_WATCHED": "تم وضع علامة على {{count}} حلقة كمشاهدة", + "SEASON_MARKED_UNWATCHED": "تم وضع علامة على الموسم كغير مشاهد", + "SEASON_MARKED_WATCHED_PARTIAL": "تم وضع علامة على {{count}} · فشل {{failed}}", + "SEASON_MARKED_UNWATCHED_PARTIAL": "تمت إزالة العلامة عن {{count}} · فشل {{failed}}", + "SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم", "EPISODES_WATCHED": "تمت مشاهدة {{count}} من {{total}} حلقة", "DETAIL_FALLBACK": { "STATUS": "البيانات الوصفية للبوابة غير متاحة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c1f6b0551..228f82a7a 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "جاري", "MARK_WATCHED": "علم كمتشاف", "MARK_UNWATCHED": "علم كما متشافش", + "MARK_SEASON_WATCHED": "علم الموسم كمتشاف ({{count}})", + "MARK_SEASON_UNWATCHED": "علم الموسم كما متشافش", + "SEASON_MARKED_WATCHED": "{{count}} ديال الحلقات تعلمو كمتشافين", + "SEASON_MARKED_UNWATCHED": "الموسم تعلم كما متشافش", + "SEASON_MARKED_WATCHED_PARTIAL": "{{count}} تعلمو · {{failed}} فشلو", + "SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} تعلمو كما متشافينش · {{failed}} فشلو", + "SEASON_WATCH_UPDATE_FAILED": "فشل تحديث الموسم", "EPISODES_WATCHED": "{{count}} من {{total}} ديال الحلقات متشافين", "DETAIL_FALLBACK": { "STATUS": "البيانات الوصفية ديال البوابة ما متوفراش", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 8b2eb88ed..6278d7560 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "У працэсе", "MARK_WATCHED": "Адзначыць як прагледжанае", "MARK_UNWATCHED": "Адзначыць як непрагледжанае", + "MARK_SEASON_WATCHED": "Адзначыць сезон як прагледжаны ({{count}})", + "MARK_SEASON_UNWATCHED": "Адзначыць сезон як непрагледжаны", + "SEASON_MARKED_WATCHED": "{{count}} эпізодаў адзначана як прагледжаныя", + "SEASON_MARKED_UNWATCHED": "Сезон адзначаны як непрагледжаны", + "SEASON_MARKED_WATCHED_PARTIAL": "{{count}} адзначана · {{failed}} не ўдалося", + "SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} знята · {{failed}} не ўдалося", + "SEASON_WATCH_UPDATE_FAILED": "Не ўдалося абнавіць сезон", "EPISODES_WATCHED": "{{count}} з {{total}} эпізодаў прагледжана", "DETAIL_FALLBACK": { "STATUS": "Метаданыя партала недаступныя", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 5edc4f043..58ed91a20 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "In Wiedergabe", "MARK_WATCHED": "Als angesehen markieren", "MARK_UNWATCHED": "Als nicht angesehen markieren", + "MARK_SEASON_WATCHED": "Staffel als angesehen markieren ({{count}})", + "MARK_SEASON_UNWATCHED": "Staffel als nicht angesehen markieren", + "SEASON_MARKED_WATCHED": "{{count}} Folgen als angesehen markiert", + "SEASON_MARKED_UNWATCHED": "Staffel als nicht angesehen markiert", + "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", "EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen", "DETAIL_FALLBACK": { "STATUS": "Portal-Metadaten fehlen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2dd11412a..a1596d1a3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "Σε εξέλιξη", "MARK_WATCHED": "Σήμανση ως προβληθέν", "MARK_UNWATCHED": "Σήμανση ως μη προβληθέν", + "MARK_SEASON_WATCHED": "Σήμανση σεζόν ως προβληθείσα ({{count}})", + "MARK_SEASON_UNWATCHED": "Σήμανση σεζόν ως μη προβληθείσα", + "SEASON_MARKED_WATCHED": "{{count}} επεισόδια σημειώθηκαν ως προβληθέντα", + "SEASON_MARKED_UNWATCHED": "Η σεζόν σημειώθηκε ως μη προβληθείσα", + "SEASON_MARKED_WATCHED_PARTIAL": "Σημειώθηκαν {{count}} · Απέτυχαν {{failed}}", + "SEASON_MARKED_UNWATCHED_PARTIAL": "Αφαιρέθηκε η σήμανση από {{count}} · Απέτυχαν {{failed}}", + "SEASON_WATCH_UPDATE_FAILED": "Η ενημέρωση της σεζόν απέτυχε", "EPISODES_WATCHED": "{{count}} από {{total}} επεισόδια προβλήθηκαν", "DETAIL_FALLBACK": { "STATUS": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1d55bcd2a..9486f6bd5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "In Progress", "MARK_WATCHED": "Mark as Watched", "MARK_UNWATCHED": "Mark as Unwatched", + "MARK_SEASON_WATCHED": "Mark season as watched ({{count}})", + "MARK_SEASON_UNWATCHED": "Mark season as unwatched", + "SEASON_MARKED_WATCHED": "{{count}} episodes marked as watched", + "SEASON_MARKED_UNWATCHED": "Season marked as unwatched", + "SEASON_MARKED_WATCHED_PARTIAL": "{{count}} marked · {{failed}} failed", + "SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} unmarked · {{failed}} failed", + "SEASON_WATCH_UPDATE_FAILED": "Updating the season failed", "EPISODES_WATCHED": "{{count}} of {{total}} episodes watched", "DETAIL_FALLBACK": { "STATUS": "Portal metadata unavailable", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a6ea616f0..94bdc7984 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "En progreso", "MARK_WATCHED": "Marcar como visto", "MARK_UNWATCHED": "Marcar como no visto", + "MARK_SEASON_WATCHED": "Marcar temporada como vista ({{count}})", + "MARK_SEASON_UNWATCHED": "Marcar temporada como no vista", + "SEASON_MARKED_WATCHED": "{{count}} episodios marcados como vistos", + "SEASON_MARKED_UNWATCHED": "Temporada marcada como no vista", + "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ó", "EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos", "DETAIL_FALLBACK": { "STATUS": "Metadatos del portal no disponibles", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b0fd26b18..e14d08730 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "En cours", "MARK_WATCHED": "Marquer comme vu", "MARK_UNWATCHED": "Marquer comme non vu", + "MARK_SEASON_WATCHED": "Marquer la saison comme vue ({{count}})", + "MARK_SEASON_UNWATCHED": "Marquer la saison comme non vue", + "SEASON_MARKED_WATCHED": "{{count}} épisodes marqués comme vus", + "SEASON_MARKED_UNWATCHED": "Saison marquée comme non vue", + "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", "EPISODES_WATCHED": "{{count}} sur {{total}} épisodes vus", "DETAIL_FALLBACK": { "STATUS": "Métadonnées du portail indisponibles", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6db627ab2..1112f8103 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "Folyamatban", "MARK_WATCHED": "Megjelölés megtekintettként", "MARK_UNWATCHED": "Megjelölés nem megtekintettként", + "MARK_SEASON_WATCHED": "Évad megjelölése megtekintettként ({{count}})", + "MARK_SEASON_UNWATCHED": "Évad megjelölése nem megtekintettként", + "SEASON_MARKED_WATCHED": "{{count}} epizód megjelölve megtekintettként", + "SEASON_MARKED_UNWATCHED": "Évad megjelölve nem megtekintettként", + "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", "EPISODES_WATCHED": "Megtekintett epizódok: {{count}} / {{total}}", "DETAIL_FALLBACK": { "STATUS": "A portál metaadatai nem állnak rendelkezésre", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4b7cb18dd..522f6f646 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "In corso", "MARK_WATCHED": "Segna come visto", "MARK_UNWATCHED": "Segna come non visto", + "MARK_SEASON_WATCHED": "Segna la stagione come vista ({{count}})", + "MARK_SEASON_UNWATCHED": "Segna la stagione come non vista", + "SEASON_MARKED_WATCHED": "{{count}} episodi segnati come visti", + "SEASON_MARKED_UNWATCHED": "Stagione segnata come non vista", + "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", "EPISODES_WATCHED": "{{count}} di {{total}} episodi visti", "DETAIL_FALLBACK": { "STATUS": "Metadati del portale non disponibili", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f0bf9559..f2b58c6b1 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "視聴中", "MARK_WATCHED": "視聴済みにする", "MARK_UNWATCHED": "未視聴にする", + "MARK_SEASON_WATCHED": "シーズンを視聴済みにする({{count}})", + "MARK_SEASON_UNWATCHED": "シーズンを未視聴にする", + "SEASON_MARKED_WATCHED": "{{count}}話を視聴済みにしました", + "SEASON_MARKED_UNWATCHED": "シーズンを未視聴にしました", + "SEASON_MARKED_WATCHED_PARTIAL": "視聴済み {{count}} · 失敗 {{failed}}", + "SEASON_MARKED_UNWATCHED_PARTIAL": "未視聴 {{count}} · 失敗 {{failed}}", + "SEASON_WATCH_UPDATE_FAILED": "シーズンの更新に失敗しました", "EPISODES_WATCHED": "{{count}} / {{total}}エピソード視聴済み", "DETAIL_FALLBACK": { "STATUS": "ポータルのメタデータは利用できません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 013fbd714..2c56d85ee 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "시청 중", "MARK_WATCHED": "시청한 것으로 표시", "MARK_UNWATCHED": "시청하지 않은 것으로 표시", + "MARK_SEASON_WATCHED": "시즌을 시청한 것으로 표시 ({{count}})", + "MARK_SEASON_UNWATCHED": "시즌을 시청하지 않은 것으로 표시", + "SEASON_MARKED_WATCHED": "{{count}}개 에피소드가 시청한 것으로 표시되었습니다", + "SEASON_MARKED_UNWATCHED": "시즌이 시청하지 않은 것으로 표시되었습니다", + "SEASON_MARKED_WATCHED_PARTIAL": "{{count}}개 표시됨 · {{failed}}개 실패", + "SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}}개 표시 해제됨 · {{failed}}개 실패", + "SEASON_WATCH_UPDATE_FAILED": "시즌 업데이트에 실패했습니다", "EPISODES_WATCHED": "{{total}}개 중 {{count}}개 에피소드 시청", "DETAIL_FALLBACK": { "STATUS": "포털 메타데이터를 사용할 수 없습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 84a5d3a98..4ad3a0d43 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "Bezig", "MARK_WATCHED": "Markeren als bekeken", "MARK_UNWATCHED": "Markeren als niet bekeken", + "MARK_SEASON_WATCHED": "Seizoen markeren als bekeken ({{count}})", + "MARK_SEASON_UNWATCHED": "Seizoen markeren als niet bekeken", + "SEASON_MARKED_WATCHED": "{{count}} afleveringen gemarkeerd als bekeken", + "SEASON_MARKED_UNWATCHED": "Seizoen gemarkeerd als niet bekeken", + "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", "EPISODES_WATCHED": "{{count}} van {{total}} afleveringen bekeken", "DETAIL_FALLBACK": { "STATUS": "Portaalmetadata niet beschikbaar", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 46c9c5766..75f30a5b9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "W trakcie", "MARK_WATCHED": "Oznacz jako obejrzane", "MARK_UNWATCHED": "Oznacz jako nieobejrzane", + "MARK_SEASON_WATCHED": "Oznacz sezon jako obejrzany ({{count}})", + "MARK_SEASON_UNWATCHED": "Oznacz sezon jako nieobejrzany", + "SEASON_MARKED_WATCHED": "Oznaczono {{count}} odcinków jako obejrzane", + "SEASON_MARKED_UNWATCHED": "Oznaczono sezon jako nieobejrzany", + "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", "EPISODES_WATCHED": "Obejrzano {{count}} z {{total}} odcinków", "DETAIL_FALLBACK": { "STATUS": "Metadane portalu niedostępne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e85a110aa..b404b4ab2 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "Em andamento", "MARK_WATCHED": "Marcar como assistido", "MARK_UNWATCHED": "Marcar como não assistido", + "MARK_SEASON_WATCHED": "Marcar temporada como assistida ({{count}})", + "MARK_SEASON_UNWATCHED": "Marcar temporada como não assistida", + "SEASON_MARKED_WATCHED": "{{count}} episódios marcados como assistidos", + "SEASON_MARKED_UNWATCHED": "Temporada marcada como não assistida", + "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", "EPISODES_WATCHED": "{{count}} de {{total}} episódios assistidos", "DETAIL_FALLBACK": { "STATUS": "Metadados do portal indisponíveis", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c20b72a80..3120f507e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "В процессе", "MARK_WATCHED": "Отметить как просмотренное", "MARK_UNWATCHED": "Отметить как непросмотренное", + "MARK_SEASON_WATCHED": "Отметить сезон как просмотренный ({{count}})", + "MARK_SEASON_UNWATCHED": "Отметить сезон как непросмотренный", + "SEASON_MARKED_WATCHED": "Отмечено как просмотренные: {{count}} эпизодов", + "SEASON_MARKED_UNWATCHED": "Сезон отмечен как непросмотренный", + "SEASON_MARKED_WATCHED_PARTIAL": "Отмечено: {{count}} · Ошибок: {{failed}}", + "SEASON_MARKED_UNWATCHED_PARTIAL": "Снято отметок: {{count}} · Ошибок: {{failed}}", + "SEASON_WATCH_UPDATE_FAILED": "Не удалось обновить сезон", "EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов", "DETAIL_FALLBACK": { "STATUS": "Метаданные портала недоступны", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 419632b63..dceba63e6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "Devam Ediyor", "MARK_WATCHED": "İzlendi Olarak İşaretle", "MARK_UNWATCHED": "İzlenmedi Olarak İşaretle", + "MARK_SEASON_WATCHED": "Sezonu İzlendi Olarak İşaretle ({{count}})", + "MARK_SEASON_UNWATCHED": "Sezonu İzlenmedi Olarak İşaretle", + "SEASON_MARKED_WATCHED": "{{count}} bölüm izlendi olarak işaretlendi", + "SEASON_MARKED_UNWATCHED": "Sezon izlenmedi olarak işaretlendi", + "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", "EPISODES_WATCHED": "{{total}} bölümden {{count}} tanesi izlendi", "DETAIL_FALLBACK": { "STATUS": "Portal üst verisi mevcut değil", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index be45a8848..daad59000 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "观看中", "MARK_WATCHED": "标记为已观看", "MARK_UNWATCHED": "标记为未观看", + "MARK_SEASON_WATCHED": "标记整季为已观看({{count}})", + "MARK_SEASON_UNWATCHED": "标记整季为未观看", + "SEASON_MARKED_WATCHED": "{{count}} 集已标记为已观看", + "SEASON_MARKED_UNWATCHED": "本季已标记为未观看", + "SEASON_MARKED_WATCHED_PARTIAL": "已标记 {{count}} · 失败 {{failed}}", + "SEASON_MARKED_UNWATCHED_PARTIAL": "已取消标记 {{count}} · 失败 {{failed}}", + "SEASON_WATCH_UPDATE_FAILED": "无法更新本季", "EPISODES_WATCHED": "已观看 {{count}} / {{total}} 集", "DETAIL_FALLBACK": { "STATUS": "门户元数据不可用", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index b2749ef5c..43acdfba6 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -893,6 +893,13 @@ "IN_PROGRESS": "觀看中", "MARK_WATCHED": "標記為已觀看", "MARK_UNWATCHED": "標記為未觀看", + "MARK_SEASON_WATCHED": "標記整季為已觀看({{count}})", + "MARK_SEASON_UNWATCHED": "標記整季為未觀看", + "SEASON_MARKED_WATCHED": "已將 {{count}} 集標記為已觀看", + "SEASON_MARKED_UNWATCHED": "本季已標記為未觀看", + "SEASON_MARKED_WATCHED_PARTIAL": "{{count}} 集已標記 · {{failed}} 集失敗", + "SEASON_MARKED_UNWATCHED_PARTIAL": "{{count}} 集已取消標記 · {{failed}} 集失敗", + "SEASON_WATCH_UPDATE_FAILED": "本季更新失敗", "EPISODES_WATCHED": "已觀看 {{total}} 集中的 {{count}} 集", "DETAIL_FALLBACK": { "STATUS": "無法取得入口網站中繼資料", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 7d9445a9f..3ecc3f8b0 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -258,6 +258,31 @@ episode" chip appears when the playing episode is outside the opened season. Season descriptions come from `get_series_info` seasons (Xtream) or `TmdbEnrichmentService.getSeason` (Stalker). +The season header carries a season-level watched toggle next to +"Download season" (`season-watch-toggle.util.ts` builds the request: +marking touches only unwatched episodes so real durations survive; +a fully watched season flips the action to unwatch-all). The container +emits one `seasonPlaybackToggleRequested` and the host persists it: +Xtream through `SerialDetailsSeasonWatchService` and the batch IPC +(`DB_SAVE_PLAYBACK_POSITIONS_BATCH` / `DB_CLEAR_PLAYBACK_POSITIONS_BATCH`, +one SQLite transaction; the PWA data source rewrites its localStorage +blob once), Stalker as synchronous per-episode enqueues through the +existing position-mutation queue so legacy-row reconciliation still runs +and the queue coalesces to a single reload; partial failures surface a +direction-specific "{{count}} marked/unmarked · {{failed}} failed" +snackbar. A stale batch completion is discarded on the Xtream side: the +host captures the playlist/series identity before awaiting and, when +navigation changed it, skips both the rendered-state mutation and the +feedback snackbar (episode ids collide across playlists and the +contextless message would read as being about the new page; the DB write +itself carries its own playlistId). Stalker gates its snackbars on the +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. + ## Components Shared detail layout shell: diff --git a/libs/portal/shared/util/src/lib/portal-playback-positions.ts b/libs/portal/shared/util/src/lib/portal-playback-positions.ts index f77682c39..b9001de9f 100644 --- a/libs/portal/shared/util/src/lib/portal-playback-positions.ts +++ b/libs/portal/shared/util/src/lib/portal-playback-positions.ts @@ -30,6 +30,19 @@ export interface PortalPlaybackPositions { contentXtreamId: number, contentType: 'vod' | 'episode' ): Promise; + /** + * Bulk variants for season-level watched toggles. Unlike the single + * save/clear methods these REJECT on failure so callers can surface an + * error instead of silently showing stale state. + */ + savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise; + clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise; } export const PORTAL_PLAYBACK_POSITIONS = diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 58f96a0b0..31f6df46f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -58,6 +58,9 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< Map >(new Map()); private loadedPositionsForPlaylistId: string | null = null; + // Latest-load-wins: a positions fetch superseded while in flight must + // not patch the maps with another playlist's rows. + private positionsLoadGeneration = 0; readonly provider = 'stalker' as const; readonly contentType = this.stalkerStore.selectedContentType; @@ -138,7 +141,11 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< } this.loadedPositionsForPlaylistId = playlistId; - void this.loadStalkerPositions(playlistId); + void this.loadStalkerPositions(playlistId).catch(() => { + // Allow a retry on the next playlist activation; the read + // now rejects instead of masquerading as an empty list. + this.loadedPositionsForPlaylistId = null; + }); }); const unsubscribe = @@ -345,9 +352,26 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< ); } + /** + * Re-read persisted positions after a renderer-initiated mutation (the + * season watched batch or a single toggle): the once-per-playlist load + * cannot see them and the runtime bridge only pushes external-player + * updates, so grid progress badges would stay stale on return. + */ + async refreshPositions(playlistId: string): Promise { + if (this.playlist()?.id !== playlistId) { + return; + } + await this.loadStalkerPositions(playlistId); + } + private async loadStalkerPositions(playlistId: string): Promise { + const generation = ++this.positionsLoadGeneration; const positions = await this.playbackPositions.getAllPlaybackPositions(playlistId); + if (generation !== this.positionsLoadGeneration) { + return; + } const positionsMap = new Map(); const seriesMap = new Map(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 45574ec73..6a2c029fa 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -209,11 +209,15 @@ isVodSeriesSeasonsLoading() || isCurrentSeasonLoading(seasonContainer?.selectedSeason()) " + [seasonWatchBatchRunning]="seasonWatchBatchRunning()" (seasonSelected)="onSeasonSelected($event)" (episodeClicked)="onEpisodeClicked($event)" (playbackToggleRequested)=" handlePlaybackToggleRequestedFromUi($event) " + (seasonPlaybackToggleRequested)=" + handleSeasonPlaybackToggleRequestedFromUi($event) + " #seasonContainer /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 4ca086a1f..59e5d4d5c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -41,9 +41,11 @@ class StubSeasonContainerComponent { readonly isLoading = input(false); readonly downloadsEnabled = input(true); readonly downloadAdapter = input(null); + readonly seasonWatchBatchRunning = input(false); readonly seasonSelected = output(); readonly episodeClicked = output(); readonly playbackToggleRequested = output(); + readonly seasonPlaybackToggleRequested = output(); readonly selectedSeason = signal(undefined); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 807d2be0c..75e16edf2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -15,6 +15,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; +import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -23,6 +24,7 @@ import { ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, + SeasonContainerSeasonPlaybackToggleRequest, } from '@iptvnator/ui/components'; import { pickSeasonMarkedTitle, @@ -168,6 +170,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { PlaybackPositionRuntimeBridgeService ); private readonly snackBar = inject(MatSnackBar); + // Optional: absent in collection-detail mounts outside the catalog. + private readonly catalogFacade = inject(StalkerCatalogFacadeService, { + optional: true, + }); private readonly translateService = inject(TranslateService); readonly backClicked = output(); private readonly logger = createLogger('StalkerSeriesView'); @@ -201,6 +207,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(null); + readonly seasonWatchBatchRunning = signal(false); /** * Optional input for VOD items with embedded series array (vclub mode) @@ -1075,10 +1082,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (request.nextPosition) { await this.persistSeriesPosition(playlistId, request.nextPosition); - return; + } else { + await this.clearSeriesPosition(playlistId, request.contentXtreamId); } - - await this.clearSeriesPosition(playlistId, request.contentXtreamId); + // Keep the catalog grid's progress badge in sync (ownership-checked + // inside the facade; no-op outside the catalog context). A failed + // refresh keeps the cache populated-but-stale. + await this.catalogFacade + ?.refreshPositions(playlistId) + .catch((error: unknown) => + this.logger.warn('Catalog position refresh failed', error) + ); } handlePlaybackToggleRequestedFromUi( @@ -1094,6 +1108,123 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } + async handleSeasonPlaybackToggleRequested( + request: SeasonContainerSeasonPlaybackToggleRequest + ): Promise { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if ( + !playlistId || + request.requests.length === 0 || + this.seasonWatchBatchRunning() + ) { + return; + } + // The mutation context already keeps a stale batch out of the next + // series' state; the snackbars need the same ownership so feedback + // for the old season is not presented on a newly opened page. + 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 { + // 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( + `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); + } + } + + handleSeasonPlaybackToggleRequestedFromUi( + request: SeasonContainerSeasonPlaybackToggleRequest + ): void { + void this.handleSeasonPlaybackToggleRequested(request).catch( + (error: unknown) => { + this.logger.error( + 'Failed to toggle season watched state', + error + ); + } + ); + } + + private notifySeasonWatchToggle(key: string, params?: object): void { + this.snackBar.open( + this.translateService.instant(key, params), + undefined, + { duration: 5000 } + ); + } + private async loadSeriesPositions( context: SeriesPositionContext ): Promise { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts index 98b04d49e..0602ddd85 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.position-compatibility.spec.ts @@ -153,22 +153,13 @@ describe('StalkerSeriesViewComponent position compatibility', () => { pendingLoad: Deferred; secondPosition: PlaybackPositionData; }> { - await startWithLoadedEpisode(); - fixture.componentInstance.vodSeriesSeasons.set([ - createSeason(SERIES_A_ID, [ - createProviderEpisode(), - createProviderEpisode('provider-episode-2', 2), - ]), - ]); - await settle(); - const [firstEpisode, secondEpisode] = - fixture.componentInstance.mappedSeasons()['1']; + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); const firstPosition = createPosition({ - contentXtreamId: Number(firstEpisode.id), + contentXtreamId: firstId, positionSeconds: 15, }); const secondPosition = createPosition({ - contentXtreamId: Number(secondEpisode.id), + contentXtreamId: secondId, episodeNumber: 2, positionSeconds: 25, }); @@ -1196,4 +1187,20 @@ describe('StalkerSeriesViewComponent position compatibility', () => { ); expect(clearPlaybackPosition).not.toHaveBeenCalled(); }); + + async function startWithTwoLoadedEpisodes(): Promise<[number, number]> { + await startWithLoadedEpisode(); + fixture.componentInstance.vodSeriesSeasons.set([ + createSeason(SERIES_A_ID, [ + createProviderEpisode(), + createProviderEpisode('provider-episode-2', 2), + ]), + ]); + await settle(); + const [first, second] = fixture.componentInstance.mappedSeasons()['1']; + return [Number(first.id), Number(second.id)]; + } + + // Season-level bulk toggle coverage lives in + // stalker-series-view.season-watch.spec.ts. }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts new file mode 100644 index 000000000..b04a34196 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts @@ -0,0 +1,400 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerVodSource, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + CrossPortalSimilarService, + DownloadsService, + PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; +import { StalkerSeriesPositionPartialSaveError } from './stalker-series-position-compatibility'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; + +const PLAYLIST_ID = 'playlist-1'; +const SERIES_A_ID = 100; +const SERIES_B_ID = 200; + +function createVodItem(seriesId: number): StalkerVodSource { + return { + id: String(seriesId), + is_series: '1', + info: { + name: `Series ${seriesId}`, + description: 'Lazy series', + movie_image: 'poster.jpg', + }, + }; +} + +function createSeason( + seriesId: number, + episodes: VodSeriesSeasonVm['episodes'] = [] +): VodSeriesSeasonVm { + return { + id: 'season-1', + video_id: String(seriesId), + name: 'Season 1', + season_number: '1', + episodes, + isLoading: false, + isExpanded: false, + }; +} + +function createProviderEpisode(id = 'provider-episode-1', episodeNumber = 1) { + return { + id, + series_number: episodeNumber, + name: episodeNumber === 1 ? 'Pilot' : `Episode ${episodeNumber}`, + }; +} + +function createPosition( + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId: 0, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 40, + durationSeconds: 100, + playlistId: PLAYLIST_ID, + ...overrides, + }; +} + +describe('StalkerSeriesViewComponent season watched toggle', () => { + let fixture: ComponentFixture; + let repositoryRows: PlaybackPositionData[]; + let repositoryOrder: string[]; + const selectedItem = signal( + createVodItem(SERIES_A_ID) + ); + const currentPlaylist = signal<{ _id: string } | null>({ + _id: PLAYLIST_ID, + }); + const vodSeriesSeasonsResource = signal([]); + const getSeriesPlaybackPositions = jest.fn(); + const savePlaybackPosition = jest.fn(); + const clearPlaybackPosition = jest.fn(); + const savePlaybackPositionOrThrow = jest.fn(); + const clearPlaybackPositionOrThrow = jest.fn(); + const refreshPositions = jest.fn(); + + async function settle(): Promise { + for (let pass = 0; pass < 4; pass++) { + fixture.detectChanges(); + await Promise.resolve(); + } + } + + async function startWithTwoLoadedEpisodes(): Promise<[number, 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(), + createProviderEpisode('provider-episode-2', 2), + ]), + ]); + await settle(); + const [first, second] = fixture.componentInstance.mappedSeasons()['1']; + return [Number(first.id), Number(second.id)]; + } + + function seasonToggleRequest(ids: number[], markWatched: boolean) { + return { + seasonKey: '1', + markWatched, + requests: ids.map((contentXtreamId, index) => ({ + contentXtreamId, + nextPosition: markWatched + ? createPosition({ + contentXtreamId, + episodeNumber: index + 1, + positionSeconds: 100, + }) + : null, + })), + }; + } + + function snackBarCalls(): unknown[][] { + return (TestBed.inject(MatSnackBar).open as jest.Mock).mock.calls; + } + + function expectSeasonToggleSnackbar(key: string): void { + expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + key, + undefined, + { duration: 5000 } + ); + } + + beforeEach(async () => { + repositoryRows = []; + repositoryOrder = []; + selectedItem.set(createVodItem(SERIES_A_ID)); + currentPlaylist.set({ _id: PLAYLIST_ID }); + vodSeriesSeasonsResource.set([]); + + getSeriesPlaybackPositions.mockReset(); + getSeriesPlaybackPositions.mockImplementation( + async ( + _playlistId: string, + seriesXtreamId: number + ): Promise => + repositoryRows.filter( + (position) => position.seriesXtreamId === seriesXtreamId + ) + ); + savePlaybackPosition.mockReset(); + savePlaybackPosition.mockImplementation( + async ( + _playlistId: string, + position: PlaybackPositionData + ): Promise => { + repositoryOrder.push(`save:${position.contentXtreamId}`); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== position.contentXtreamId + ); + repositoryRows.push(position); + } + ); + clearPlaybackPosition.mockReset(); + clearPlaybackPosition.mockImplementation( + async ( + _playlistId: string, + contentXtreamId: number + ): Promise => { + repositoryOrder.push(`clear:${contentXtreamId}`); + repositoryRows = repositoryRows.filter( + (row) => row.contentXtreamId !== contentXtreamId + ); + } + ); + savePlaybackPositionOrThrow + .mockReset() + .mockImplementation(savePlaybackPosition); + clearPlaybackPositionOrThrow + .mockReset() + .mockImplementation(clearPlaybackPosition); + refreshPositions.mockReset(); + refreshPositions.mockResolvedValue(undefined); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType: signal<'series' | 'vod'>('vod'), + currentPlaylist, + getSerialSeasonsResource: () => [], + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes: jest + .fn() + .mockResolvedValue([createProviderEpisode()]), + resolveVodPlayback: jest.fn(), + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition, + clearPlaybackPosition, + savePlaybackPositionOrThrow, + clearPlaybackPositionOrThrow, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest + .fn() + .mockReturnValue(jest.fn()), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { + provide: CrossPortalSimilarService, + useValue: { + isAvailable: false, + matchRecommendations: jest.fn(), + buildLink: jest.fn(), + }, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + navigateByUrl: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { startDownload: jest.fn() }, + }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { open: jest.fn() }, + }, + { + provide: StalkerCatalogFacadeService, + useValue: { refreshPositions }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => { + fixture.destroy(); + jest.restoreAllMocks(); + }); + + it('marks a season watched sequentially, counting failed legacy cleanup as watched', async () => { + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); + const loadsBefore = getSeriesPlaybackPositions.mock.calls.length; + // The second episode saves its scoped row but fails legacy cleanup — + // that outcome is still a watched episode, not a batch failure. + savePlaybackPositionOrThrow.mockImplementation( + async (playlistId: string, position: PlaybackPositionData) => { + await savePlaybackPosition(playlistId, position); + if (position.contentXtreamId === secondId) { + throw new StalkerSeriesPositionPartialSaveError('cleanup'); + } + } + ); + + await fixture.componentInstance.handleSeasonPlaybackToggleRequested( + seasonToggleRequest([firstId, secondId], true) + ); + + expect(repositoryOrder).toEqual([ + `save:${firstId}`, + `save:${secondId}`, + ]); + expect(getSeriesPlaybackPositions.mock.calls.length).toBe(loadsBefore); + const positions = fixture.componentInstance.episodePlaybackPositions(); + expect(positions.get(secondId)?.positionSeconds).toBe(100); + expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_WATCHED'); + // The catalog grid's progress badge source must follow the batch. + expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false); + }); + + it('keeps surviving clears and reports a partial season unwatch failure', async () => { + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); + repositoryRows = [firstId, secondId].map((contentXtreamId, index) => + createPosition({ + contentXtreamId, + episodeNumber: index + 1, + positionSeconds: 100, + }) + ); + selectedItem.set(createVodItem(SERIES_A_ID)); + await settle(); + clearPlaybackPositionOrThrow.mockImplementation( + async (playlistId: string, contentXtreamId: number) => { + if (contentXtreamId === secondId) { + throw new Error('clear rejected'); + } + return clearPlaybackPosition(playlistId, contentXtreamId); + } + ); + + await fixture.componentInstance.handleSeasonPlaybackToggleRequested( + seasonToggleRequest([firstId, secondId], false) + ); + + const positions = fixture.componentInstance.episodePlaybackPositions(); + expect(positions.has(firstId)).toBe(false); + expect(positions.get(secondId)?.positionSeconds).toBe(100); + expectSeasonToggleSnackbar('XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL'); + // A partial success changed rows — the badge refresh still runs. + expect(refreshPositions).toHaveBeenCalledWith(PLAYLIST_ID); + }); + + it('suppresses feedback when the series changes while the batch drains', async () => { + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); + const callsBefore = snackBarCalls().length; + + const pending = + fixture.componentInstance.handleSeasonPlaybackToggleRequested( + seasonToggleRequest([firstId, secondId], true) + ); + // The user opens another series before the mutation queue drains. + selectedItem.set(createVodItem(SERIES_B_ID)); + await pending; + + expect(snackBarCalls().length).toBe(callsBefore); + expect(fixture.componentInstance.seasonWatchBatchRunning()).toBe(false); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 39f51eb64..09b1f7938 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -72,8 +72,11 @@ export function createPlaybackServiceMock() { getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), getRecentPlaybackPositions: jest.fn().mockResolvedValue([]), getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]), clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), + clearPlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), }; } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 354f2b7fe..ea8c8bf37 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -516,7 +516,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async getAllPlaybackPositions( playlistId: string ): Promise { - return this.playbackService.getAllPlaybackPositions(playlistId); + // Failure-propagating on purpose: the store and catalog caches must + // not mistake a failed read for an authoritative empty list. + return this.playbackService.getAllPlaybackPositionsOrThrow(playlistId); } async clearPlaybackPosition( @@ -531,6 +533,26 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ); } + async savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + await this.playbackService.savePlaybackPositionsBatch( + playlistId, + items + ); + } + + async clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise { + await this.playbackService.clearPlaybackPositionsBatch( + playlistId, + items + ); + } + // ========================================================================= // Cleanup Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 7169fbd39..6a9cfe685 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -123,9 +123,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { harness.playbackService.getRecentPlaybackPositions.mockResolvedValue( [position] ); - harness.playbackService.getAllPlaybackPositions.mockResolvedValue([ - position, - ]); + harness.playbackService.getAllPlaybackPositionsOrThrow.mockResolvedValue( + [position] + ); await harness.dataSource.savePlaybackPosition(playlistId, position); expect( @@ -149,9 +149,14 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { harness.playbackService.getRecentPlaybackPositions ).toHaveBeenCalledWith(playlistId, 5); + // The data source uses the failure-propagating read so cache + // refreshes cannot mistake a swallowed error for an empty list. await expect( harness.dataSource.getAllPlaybackPositions(playlistId) ).resolves.toEqual([position]); + expect( + harness.playbackService.getAllPlaybackPositionsOrThrow + ).toHaveBeenCalledWith(playlistId); await harness.dataSource.clearPlaybackPosition( playlistId, @@ -162,6 +167,33 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { harness.playbackService.clearPlaybackPosition ).toHaveBeenCalledWith(playlistId, 202, 'vod'); }); + + it('delegates batch playback position writes to the playback service', async () => { + const items = [position]; + const clearItems: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + }[] = [ + { contentXtreamId: 202, contentType: 'vod' }, + { contentXtreamId: 303, contentType: 'episode' }, + ]; + + await harness.dataSource.savePlaybackPositionsBatch( + playlistId, + items + ); + expect( + harness.playbackService.savePlaybackPositionsBatch + ).toHaveBeenCalledWith(playlistId, items); + + await harness.dataSource.clearPlaybackPositionsBatch( + playlistId, + clearItems + ); + expect( + harness.playbackService.clearPlaybackPositionsBatch + ).toHaveBeenCalledWith(playlistId, clearItems); + }); }); describe('cleanup operations', () => { diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 35b55890b..2ee488a7c 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; import { XtreamApiService, @@ -601,4 +602,154 @@ describe('PwaXtreamDataSource', () => { ]); expect(apiService.getStreams).not.toHaveBeenCalled(); }); + + describe('playback position batches', () => { + const storageKey = 'xtream-playback-positions'; + + const position = ( + overrides: Partial = {} + ): PlaybackPositionData => ({ + contentXtreamId: 100, + contentType: 'vod', + positionSeconds: 42, + durationSeconds: 5400, + playlistId: 'playlist-1', + ...overrides, + }); + + it('writes a playback-position batch save as one storage write with upsert semantics', async () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + 'playlist-1': [ + position({ + contentXtreamId: 100, + contentType: 'episode', + positionSeconds: 10, + updatedAt: '2026-01-01T00:00:00.000Z', + }), + ], + 'playlist-2': [ + position({ + playlistId: 'playlist-2', + contentXtreamId: 900, + updatedAt: '2026-01-01T00:00:00.000Z', + }), + ], + }) + ); + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + await dataSource.savePlaybackPositionsBatch('playlist-1', [ + position({ + contentXtreamId: 100, + contentType: 'episode', + positionSeconds: 1200, + }), + position({ + contentXtreamId: 101, + contentType: 'episode', + positionSeconds: 1300, + }), + ]); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + setItemSpy.mockRestore(); + + const stored = JSON.parse( + localStorage.getItem(storageKey) || '{}' + ); + const playlistRows = stored[ + 'playlist-1' + ] as PlaybackPositionData[]; + expect(playlistRows).toHaveLength(2); + + const replaced = playlistRows.find( + (row) => row.contentXtreamId === 100 + ); + const appended = playlistRows.find( + (row) => row.contentXtreamId === 101 + ); + expect(replaced).toEqual( + expect.objectContaining({ + contentType: 'episode', + positionSeconds: 1200, + }) + ); + expect(replaced?.updatedAt).not.toBe('2026-01-01T00:00:00.000Z'); + // The whole batch is stamped with one shared timestamp. + expect(appended?.updatedAt).toBe(replaced?.updatedAt); + expect(stored['playlist-2']).toEqual([ + expect.objectContaining({ + contentXtreamId: 900, + updatedAt: '2026-01-01T00:00:00.000Z', + }), + ]); + }); + + it('removes only the named rows in a single write on batch clear', async () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + 'playlist-1': [ + position({ + contentXtreamId: 100, + contentType: 'episode', + }), + position({ + contentXtreamId: 101, + contentType: 'episode', + }), + position({ contentXtreamId: 202 }), + ], + 'playlist-2': [ + position({ + playlistId: 'playlist-2', + contentXtreamId: 100, + contentType: 'episode', + }), + ], + }) + ); + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + await dataSource.clearPlaybackPositionsBatch('playlist-1', [ + { contentXtreamId: 100, contentType: 'episode' }, + { contentXtreamId: 101, contentType: 'episode' }, + ]); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + setItemSpy.mockRestore(); + + const stored = JSON.parse( + localStorage.getItem(storageKey) || '{}' + ); + expect(stored['playlist-1']).toEqual([ + expect.objectContaining({ + contentXtreamId: 202, + contentType: 'vod', + }), + ]); + // Another playlist's row with the same episode key survives. + expect(stored['playlist-2']).toEqual([ + expect.objectContaining({ + contentXtreamId: 100, + contentType: 'episode', + }), + ]); + }); + + it('skips storage writes for empty batches and missing playlist buckets', async () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + await dataSource.savePlaybackPositionsBatch('playlist-1', []); + await dataSource.clearPlaybackPositionsBatch('playlist-1', []); + await dataSource.clearPlaybackPositionsBatch('playlist-1', [ + { contentXtreamId: 100, contentType: 'vod' }, + ]); + + expect(setItemSpy).not.toHaveBeenCalled(); + setItemSpy.mockRestore(); + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index e2de3e839..67a5f2f4c 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -727,6 +727,59 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } } + async savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + if (items.length === 0) { + return; + } + + // One blob read + one write for the whole batch — the blob holds + // every playlist's positions, so per-item saves would re-serialize + // it N times. + const allPositions = this.getPlaybackPositionsFromStorage(); + const playlistPositions = allPositions[playlistId] ?? []; + const updatedAt = new Date().toISOString(); + + const replacedKeys = new Set( + items.map((item) => `${item.contentType}_${item.contentXtreamId}`) + ); + allPositions[playlistId] = [ + ...playlistPositions.filter( + (p) => + !replacedKeys.has(`${p.contentType}_${p.contentXtreamId}`) + ), + ...items.map((item) => ({ ...item, updatedAt })), + ]; + + this.savePlaybackPositionsToStorage(allPositions); + } + + async clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise { + if (items.length === 0) { + return; + } + + const allPositions = this.getPlaybackPositionsFromStorage(); + const playlistPositions = allPositions[playlistId]; + if (!playlistPositions) { + return; + } + + const removedKeys = new Set( + items.map((item) => `${item.contentType}_${item.contentXtreamId}`) + ); + allPositions[playlistId] = playlistPositions.filter( + (p) => !removedKeys.has(`${p.contentType}_${p.contentXtreamId}`) + ); + + this.savePlaybackPositionsToStorage(allPositions); + } + private getPlaybackPositionsFromStorage(): Record< string, PlaybackPositionData[] diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index feb136a3b..e5c9cf12b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -430,6 +430,24 @@ export interface IXtreamDataSource { contentType: 'vod' | 'episode' ): Promise; + /** + * Save/update many playback positions at once (season-level "mark as + * watched"). Rejects when nothing was persisted. + */ + savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise; + + /** + * Clear many playback positions at once (season-level "mark as + * unwatched"). Rejects when nothing was cleared. + */ + clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise; + // ========================================================================= // Cleanup Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.spec.ts new file mode 100644 index 000000000..a7ab1ba6a --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore } from '@ngrx/signals'; +import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface'; +import { withPlaybackPositions } from './with-playback-positions.feature'; + +function position( + contentXtreamId: number, + playlistId: string +): PlaybackPositionData { + return { + contentXtreamId, + contentType: 'episode', + seriesXtreamId: 7, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 50, + durationSeconds: 100, + playlistId, + }; +} + +describe('withPlaybackPositions', () => { + const TestStore = signalStore( + { providedIn: 'root' }, + withPlaybackPositions() + ); + const getAllPlaybackPositions = jest.fn(); + + function createStore() { + TestBed.configureTestingModule({ + providers: [ + { + provide: XTREAM_DATA_SOURCE, + useValue: { getAllPlaybackPositions }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest + .fn() + .mockReturnValue(jest.fn()), + }, + }, + ], + }); + return TestBed.inject(TestStore); + } + + beforeEach(() => { + getAllPlaybackPositions.mockReset(); + }); + + it('loads positions into the store maps', async () => { + getAllPlaybackPositions.mockResolvedValue([position(11, 'playlist-a')]); + const store = createStore(); + + await store.loadAllPositions('playlist-a'); + + expect(store.playbackPositions().get('episode_11')).toBeDefined(); + expect(store.hasSeriesProgress(7)).toBe(true); + }); + + it('keeps the cached maps when a refresh read fails', async () => { + getAllPlaybackPositions.mockResolvedValueOnce([ + position(11, 'playlist-a'), + ]); + const store = createStore(); + await store.loadAllPositions('playlist-a'); + + getAllPlaybackPositions.mockRejectedValueOnce(new Error('ipc down')); + await expect(store.loadAllPositions('playlist-a')).rejects.toThrow( + 'ipc down' + ); + + expect(store.playbackPositions().get('episode_11')).toBeDefined(); + expect(store.hasSeriesProgress(7)).toBe(true); + }); + + it('discards a superseded load so a late result cannot overwrite the newer playlist', async () => { + let resolveFirst!: (rows: PlaybackPositionData[]) => void; + getAllPlaybackPositions + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce([position(22, 'playlist-b')]); + const store = createStore(); + + const stale = store.loadAllPositions('playlist-a'); + await store.loadAllPositions('playlist-b'); + + resolveFirst([position(11, 'playlist-a')]); + await stale; + + expect(store.playbackPositions().has('episode_11')).toBe(false); + expect(store.playbackPositions().get('episode_22')).toBeDefined(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts index a1d39f2ac..36a8a8235 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-playback-positions.feature.ts @@ -6,7 +6,6 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { PlaybackPositionData, @@ -23,36 +22,6 @@ const initialState: PlaybackPositionsState = { seriesPositions: new Map(), }; -function parseDuration(duration: string | number): number { - if (typeof duration === 'number') return duration; - if (!duration) return 0; - - // Check for "min" format (e.g. "45 min") - const minMatch = duration.match(/(\d+)\s*min/); - if (minMatch) { - return parseInt(minMatch[1], 10) * 60; - } - - // Check for "h:m:s" or "m:s" format - if (duration.includes(':')) { - const parts = duration.split(':').map((p) => parseInt(p, 10)); - if (parts.length === 3) { - return parts[0] * 3600 + parts[1] * 60 + parts[2]; - } else if (parts.length === 2) { - return parts[0] * 60 + parts[1]; - } - } - - // Fallback: try parsing as simple number (seconds or minutes? assume minutes if < 1000, seconds otherwise?) - // Xtream usually returns seconds or "min" string - const num = parseInt(duration, 10); - if (!isNaN(num)) { - return num; - } - - return 0; -} - export function withPlaybackPositions() { return signalStoreFeature( withState(initialState), @@ -63,6 +32,12 @@ export function withPlaybackPositions() { const getPositionKey = (type: string, id: number) => `${type}_${id}`; + // Latest-load-wins: a load that was superseded while its fetch + // was in flight must not patch the store — after a playlist + // switch the late result would overwrite the new playlist's + // position maps with the old playlist's rows. + let positionsLoadGeneration = 0; + return { /** * Get progress percentage for display (0-100) @@ -125,8 +100,12 @@ export function withPlaybackPositions() { * Load all playback positions for the playlist (for grid view) */ async loadAllPositions(playlistId: string): Promise { + const generation = ++positionsLoadGeneration; const positions = await dataSource.getAllPlaybackPositions(playlistId); + if (generation !== positionsLoadGeneration) { + return; + } const positionsMap = new Map< string, @@ -235,105 +214,6 @@ export function withPlaybackPositions() { updated.set(key, data); patchState(store, { playbackPositions: updated }); }, - - /** - * Toggle watched status for an episode - */ - async toggleEpisodeWatched( - playlistId: string, - episode: XtreamSerieEpisode, - seriesId: number - ): Promise { - const id = Number(episode.id); - const isWatched = this.isWatched(id, 'episode'); - - if (isWatched) { - // Mark as unwatched - await dataSource.clearPlaybackPosition( - playlistId, - id, - 'episode' - ); - // Update state (remove from map) - const key = getPositionKey('episode', id); - const updated = new Map(store.playbackPositions()); - updated.delete(key); - - // Update series map - const seriesMap = new Map(store.seriesPositions()); - const seriesEpisodes = seriesMap.get(seriesId) || []; - const filteredEpisodes = seriesEpisodes.filter( - (p) => p.contentXtreamId !== id - ); - if (filteredEpisodes.length === 0) { - seriesMap.delete(seriesId); - } else { - seriesMap.set(seriesId, filteredEpisodes); - } - - patchState(store, { - playbackPositions: updated, - seriesPositions: seriesMap, - }); - } else { - // Mark as watched - let duration = 0; - const info = Array.isArray(episode.info) - ? null - : episode.info; - - if (info?.duration_secs) { - duration = info.duration_secs; - } else if (info?.duration) { - duration = parseDuration(info.duration); - } - - if (duration === 0) duration = 1; // Fallback - - // Setting position = duration indicates episode is fully watched - const data: PlaybackPositionData = { - contentXtreamId: id, - contentType: 'episode', - seriesXtreamId: seriesId, - seasonNumber: Number(episode.season), - episodeNumber: Number(episode.episode_num), - positionSeconds: duration, - durationSeconds: duration, - playlistId, - updatedAt: new Date().toISOString(), - }; - - // Use existing savePosition to handle state update and persistence - // But we also need to update seriesPositions map which savePosition doesn't do for individual updates - // actually savePosition only updates playbackPositions map. - // We should probably update savePosition to also update series map, or do it here. - // Let's do it here to be safe. - - await dataSource.savePlaybackPosition(playlistId, data); - - const key = getPositionKey('episode', id); - const updated = new Map(store.playbackPositions()); - updated.set(key, data); - - const seriesMap = new Map(store.seriesPositions()); - const seriesEpisodes = seriesMap.get(seriesId) || []; - // Check if already exists - const existingIdx = seriesEpisodes.findIndex( - (p) => p.contentXtreamId === id - ); - if (existingIdx >= 0) { - seriesEpisodes[existingIdx] = data; - } else { - seriesEpisodes.push(data); - } - seriesMap.set(seriesId, seriesEpisodes); - - patchState(store, { - playbackPositions: updated, - seriesPositions: seriesMap, - }); - } - }, }; }), diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts index d0dd1afee..c3ea64999 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts @@ -110,6 +110,42 @@ describe('SerialDetailsPlaybackPositionState', () => { }); }); + describe('bulk mutators', () => { + it('updates many positions with a single map replacement', async () => { + await loadPositions([position(1001)]); + const before = state.positions(); + + state.updateMany([ + position(1002, { episodeNumber: 2 }), + position(2001, { seasonNumber: 2 }), + ]); + + expect(state.positions()).not.toBe(before); + expect(state.positions().size).toBe(3); + expect(state.positions().get(1002)?.episodeNumber).toBe(2); + + state.updateMany([]); + expect(state.positions().size).toBe(3); + }); + + it('removes many positions and keeps the rest', async () => { + await loadPositions([ + position(1001), + position(1002), + position(2001), + ]); + + state.removeMany([1001, 2001]); + + expect(state.positions().size).toBe(1); + expect(state.positions().has(1002)).toBe(true); + + const current = state.positions(); + state.removeMany([]); + expect(state.positions()).toBe(current); + }); + }); + describe('takeResumeEpisode', () => { it('rejects targets before positions finish loading', () => { expect( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts index 4f0f8283a..365bfd150 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts @@ -117,12 +117,34 @@ export class SerialDetailsPlaybackPositionState { this.positions.set(updated); } + updateMany(positions: PlaybackPositionData[]): void { + if (positions.length === 0) { + return; + } + const updated = new Map(this.positions()); + for (const position of positions) { + updated.set(position.contentXtreamId, position); + } + this.positions.set(updated); + } + remove(contentXtreamId: number): void { const updated = new Map(this.positions()); updated.delete(contentXtreamId); this.positions.set(updated); } + removeMany(contentXtreamIds: number[]): void { + if (contentXtreamIds.length === 0) { + return; + } + const updated = new Map(this.positions()); + for (const contentXtreamId of contentXtreamIds) { + updated.delete(contentXtreamId); + } + this.positions.set(updated); + } + async recordExternalLaunch( playback: ResolvedPortalPlayback, launch: Promise, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 7537a272c..c083c8ecf 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -25,7 +25,10 @@ import { XtreamSerieDetails, XtreamSerieEpisode, } from '@iptvnator/shared/interfaces'; -import { SeasonContainerPlaybackToggleRequest } from '@iptvnator/ui/components'; +import { + SeasonContainerPlaybackToggleRequest, + SeasonContainerSeasonPlaybackToggleRequest, +} from '@iptvnator/ui/components'; import { getSeriesEpisodeMetadata, getSeriesPlaybackNavigation, @@ -35,6 +38,7 @@ 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'; export type XtreamSerieDetailsView = XtreamSerieDetails & { readonly series_id: number; @@ -60,6 +64,7 @@ export class SerialDetailsPlaybackService { private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET); + private readonly seasonWatch = inject(SerialDetailsSeasonWatchService); private readonly bindings = signal( null @@ -77,6 +82,7 @@ export class SerialDetailsPlaybackService { readonly episodePlaybackPositions = this.playbackPositionState.positions; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(null); + readonly seasonWatchBatchRunning = this.seasonWatch.batchRunning; readonly quickStartAction = computed(() => { const item = this.selectedItem(); @@ -326,15 +332,55 @@ export class SerialDetailsPlaybackService { request.nextPosition ); this.playbackPositionState.update(request.nextPosition); + } else { + await this.playbackPositions.clearPlaybackPosition( + playlistId, + request.contentXtreamId, + 'episode' + ); + this.playbackPositionState.remove(request.contentXtreamId); + } + await this.refreshStorePositions(playlistId); + } + + async handleSeasonPlaybackToggleRequested( + request: SeasonContainerSeasonPlaybackToggleRequest + ): Promise { + const playlistId = this.currentPlaylistId(); + const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0); + const persisted = await this.seasonWatch.handle( + request, + playlistId, + this.playbackPositionState, + () => + this.currentPlaylistId() === playlistId && + Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId + ); + if (persisted) { + await this.refreshStorePositions(playlistId); + } + } + + /** + * The catalog reads series progress from XtreamStore, whose positions + * load once per playlist (XtreamCatalogFacadeService.initialize), so a + * toggle must push the change back or badges go stale on return. Skipped + * after a playlist switch — the store then holds the other playlist. + */ + private async refreshStorePositions(playlistId: string): Promise { + if (this.currentPlaylistId() !== playlistId) { return; } - - await this.playbackPositions.clearPlaybackPosition( - playlistId, - request.contentXtreamId, - 'episode' - ); - this.playbackPositionState.remove(request.contentXtreamId); + try { + await this.xtreamStore.loadAllPositions(playlistId); + } catch (error) { + // The toggle itself succeeded; a failed refresh keeps the store + // populated-but-stale, which beats wiping it with a bad read. + console.warn( + '[SerialDetailsPlayback] Store position refresh failed', + error + ); + } } async loadSeriesPlaybackPositions( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-season-watch.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-season-watch.service.ts new file mode 100644 index 000000000..82626d53f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-season-watch.service.ts @@ -0,0 +1,98 @@ +import { Injectable, inject, signal } from '@angular/core'; +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 { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; + +/** + * Season-level bulk watched toggle for the serial details view: one batch + * persistence call, host state update, and user feedback. + */ +@Injectable() +export class SerialDetailsSeasonWatchService { + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + readonly batchRunning = signal(false); + + async handle( + request: SeasonContainerSeasonPlaybackToggleRequest, + playlistId: string, + state: Pick< + SerialDetailsPlaybackPositionState, + 'updateMany' | 'removeMany' + >, + // The component is reused across detail navigations and resets the + // position state for the next series while a batch may still be in + // flight; a stale completion must not write the old series' rows + // 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 + ): Promise { + if (!playlistId || request.requests.length === 0 || this.batchRunning()) { + return false; + } + + this.batchRunning.set(true); + try { + if (request.markWatched) { + const positions = request.requests + .map((item) => item.nextPosition) + .filter( + (position): position is PlaybackPositionData => + position !== null + ); + await this.playbackPositions.savePlaybackPositionsBatch( + playlistId, + positions + ); + if (!stillCurrent()) { + return true; + } + state.updateMany(positions); + this.notify('XTREAM.SEASON_MARKED_WATCHED', { + count: positions.length, + }); + } else { + await this.playbackPositions.clearPlaybackPositionsBatch( + playlistId, + request.requests.map((item) => ({ + contentXtreamId: item.contentXtreamId, + contentType: 'episode' as const, + })) + ); + if (!stillCurrent()) { + return true; + } + state.removeMany( + request.requests.map((item) => item.contentXtreamId) + ); + this.notify('XTREAM.SEASON_MARKED_UNWATCHED'); + } + 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', + error + ); + if (stillCurrent()) { + this.notify('XTREAM.SEASON_WATCH_UPDATE_FAILED'); + } + return false; + } finally { + this.batchRunning.set(false); + } + } + + private notify(key: string, params?: object): void { + this.snackBar.open(this.translate.instant(key, params), undefined, { + duration: 5000, + }); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 46c230a27..a3de23b19 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -215,8 +215,12 @@ inlinePlayback()?.contentInfo?.contentXtreamId ?? null " [seasonDescriptions]="seasonDescriptions()" + [seasonWatchBatchRunning]="seasonWatchBatchRunning()" (episodeClicked)="playEpisode($event)" (playbackToggleRequested)="handlePlaybackToggleRequested($event)" + (seasonPlaybackToggleRequested)=" + handleSeasonPlaybackToggleRequested($event) + " (seasonSelected)="onSeasonSelected($event)" /> diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 3805770f4..102dfb551 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -45,8 +45,10 @@ class StubSeasonContainerComponent { readonly activeEpisodeId = input(null); readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); + readonly seasonWatchBatchRunning = input(false); readonly episodeClicked = output(); readonly playbackToggleRequested = output(); + readonly seasonPlaybackToggleRequested = output(); } @Component({ @@ -103,6 +105,9 @@ describe('SerialDetailsComponent', () => { const openExternalPlayback = jest.fn(); const savePlaybackPosition = jest.fn(); const clearPlaybackPosition = jest.fn(); + const savePlaybackPositionsBatch = jest.fn(); + const clearPlaybackPositionsBatch = jest.fn(); + const loadAllPositions = jest.fn(); const isEmbeddedPlayer = jest.fn(); const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null = @@ -174,6 +179,12 @@ describe('SerialDetailsComponent', () => { savePlaybackPosition.mockResolvedValue(undefined); clearPlaybackPosition.mockReset(); clearPlaybackPosition.mockResolvedValue(undefined); + savePlaybackPositionsBatch.mockReset(); + savePlaybackPositionsBatch.mockResolvedValue(undefined); + clearPlaybackPositionsBatch.mockReset(); + clearPlaybackPositionsBatch.mockResolvedValue(undefined); + loadAllPositions.mockReset(); + loadAllPositions.mockResolvedValue(undefined); positionUpdateCallback = null; isEmbeddedPlayer.mockReset(); isEmbeddedPlayer.mockReturnValue(false); @@ -219,6 +230,7 @@ describe('SerialDetailsComponent', () => { constructEpisodeStreamUrl, addRecentItem, backfillContentMetadata: jest.fn(), + loadAllPositions, }, }, { @@ -233,6 +245,8 @@ describe('SerialDetailsComponent', () => { getSeriesPlaybackPositions, savePlaybackPosition, clearPlaybackPosition, + savePlaybackPositionsBatch, + clearPlaybackPositionsBatch, }, }, { @@ -890,6 +904,183 @@ describe('SerialDetailsComponent', () => { ); }); + it('marks a season watched through one batch save and updates rendered positions', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + const seasonPosition = (contentXtreamId: number, episodeNumber: number) => ({ + playlistId: 'xtream-1', + contentXtreamId, + contentType: 'episode' as const, + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber, + positionSeconds: 1200, + durationSeconds: 1200, + }); + + await playbackService.handleSeasonPlaybackToggleRequested({ + seasonKey: '1', + markWatched: true, + requests: [ + { contentXtreamId: 1001, nextPosition: seasonPosition(1001, 1) }, + { contentXtreamId: 1002, nextPosition: seasonPosition(1002, 2) }, + ], + } as never); + + expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + expect.objectContaining({ contentXtreamId: 1001 }), + expect.objectContaining({ contentXtreamId: 1002 }), + ]); + expect(savePlaybackPosition).not.toHaveBeenCalled(); + expect( + playbackService.episodePlaybackPositions().get(1001) + ).toEqual(expect.objectContaining({ positionSeconds: 1200 })); + expect( + playbackService.episodePlaybackPositions().get(1002) + ).toBeDefined(); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_MARKED_WATCHED', + undefined, + { duration: 5000 } + ); + // The catalog badge source must follow the batch. + expect(loadAllPositions).toHaveBeenCalledWith('xtream-1'); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + }); + + it('unwatches a season through one batch clear', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + 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); + + expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + { contentXtreamId: 1001, contentType: 'episode' }, + { contentXtreamId: 1002, contentType: 'episode' }, + ]); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + }); + + it('does not write a stale season batch into another playlist state', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + let resolveBatch!: () => void; + savePlaybackPositionsBatch.mockImplementation( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const pending = playbackService.handleSeasonPlaybackToggleRequested({ + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never); + + // The user navigates to another playlist while the batch is pending. + const initialPlaylist = currentPlaylist(); + currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' }); + resolveBatch(); + await pending; + + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith( + 'xtream-1', + expect.anything() + ); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + // The store now belongs to the other playlist — no stale refresh. + expect(loadAllPositions).not.toHaveBeenCalled(); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + currentPlaylist.set(initialPlaylist); + }); + + it('keeps rendered positions and reports the error when the season batch fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + savePlaybackPositionsBatch.mockRejectedValue( + new Error('batch failed') + ); + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + await playbackService.handleSeasonPlaybackToggleRequested({ + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never); + + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_WATCH_UPDATE_FAILED', + undefined, + { duration: 5000 } + ); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + consoleError.mockRestore(); + }); + it('passes inline episode metadata and autoplays only inside the current season', async () => { isEmbeddedPlayer.mockReturnValue(true); fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 7b0c0874f..ef1d074aa 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -22,6 +22,7 @@ import { ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, + SeasonContainerSeasonPlaybackToggleRequest, } from '@iptvnator/ui/components'; import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { @@ -52,6 +53,7 @@ import { SerialDetailsPlaybackService, type XtreamSerieDetailsView, } from './serial-details-playback.service'; +import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; import { SimilarCatalogItem, matchRecommendationsToCatalog, @@ -76,7 +78,7 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; } `, ], - providers: [SerialDetailsPlaybackService], + providers: [SerialDetailsPlaybackService, SerialDetailsSeasonWatchService], imports: [ DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -154,6 +156,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly inlinePlayback = this.playback.inlinePlayback; readonly episodePlaybackPositions = this.playback.episodePlaybackPositions; readonly openingEpisodeId = this.playback.openingEpisodeId; + readonly seasonWatchBatchRunning = this.playback.seasonWatchBatchRunning; readonly activeEpisodeId = this.playback.activeEpisodeId; readonly quickStartAction = this.playback.quickStartAction; readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata; @@ -388,6 +391,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { return this.playback.handlePlaybackToggleRequested(request); } + handleSeasonPlaybackToggleRequested( + request: SeasonContainerSeasonPlaybackToggleRequest + ): Promise { + return this.playback.handleSeasonPlaybackToggleRequested(request); + } + toggleFavorite(): void { const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) { diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts index 302c363ff..8d318d314 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -60,7 +60,7 @@ describe('XtreamCatalogFacadeService', () => { contentSortMode, minRating, currentPlaylist, - loadAllPositions: jest.fn(), + loadAllPositions: jest.fn().mockResolvedValue(undefined), setCategorySearchTerm: jest.fn(), setSelectedItem: jest.fn((item: Record | null) => { selectedItem.set(item); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 21c8f156f..317cd855a 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -85,7 +85,11 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< const playlistId = this.xtreamStore.currentPlaylist()?.id; if (playlistId && this.loadedPositionsPlaylistId !== playlistId) { this.loadedPositionsPlaylistId = playlistId; - this.xtreamStore.loadAllPositions(playlistId); + // A failed initial load leaves the maps empty (same as before); + // the read now rejects instead of masquerading as empty. + void this.xtreamStore.loadAllPositions(playlistId).catch(() => { + this.loadedPositionsPlaylistId = null; + }); } this.clearSelectedItem(); diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts index 275cbffdb..813e35f50 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.spec.ts @@ -7,6 +7,23 @@ import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; import { PlaybackPositionRuntimeBridgeService } from './playback-position-runtime-bridge.service'; +const batchSaveItems: PlaybackPositionData[] = [ + createPosition(), + createPosition({ + contentXtreamId: 101, + contentType: 'episode', + seriesXtreamId: 200, + }), +]; + +const batchClearItems: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; +}[] = [ + { contentXtreamId: 100, contentType: 'vod' }, + { contentXtreamId: 101, contentType: 'episode' }, +]; + describe('PlaybackPositionRuntimeBridgeService', () => { let service: PlaybackPositionRuntimeBridgeService; let injector: DestroyableInjector; @@ -323,6 +340,108 @@ describe('PlaybackPositionRuntimeBridgeService', () => { await expect(operation.invokeLenient(service)).rejects.toBe(error); }); }); + + describe.each([ + { + name: 'batch save', + items: batchSaveItems as unknown[], + installBridge: (implementation: jest.Mock) => { + window.electron = { + ...window.electron, + dbSavePlaybackPositionsBatch: implementation, + } as unknown as typeof window.electron; + }, + invoke: (target: PlaybackPositionRuntimeBridgeService) => + target.savePlaybackPositionsBatch( + 'playlist-1', + batchSaveItems + ), + invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) => + target.savePlaybackPositionsBatch('playlist-1', []), + }, + { + name: 'batch clear', + items: batchClearItems as unknown[], + installBridge: (implementation: jest.Mock) => { + window.electron = { + ...window.electron, + dbClearPlaybackPositionsBatch: implementation, + } as unknown as typeof window.electron; + }, + invoke: (target: PlaybackPositionRuntimeBridgeService) => + target.clearPlaybackPositionsBatch( + 'playlist-1', + batchClearItems + ), + invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) => + target.clearPlaybackPositionsBatch('playlist-1', []), + }, + ])('$name persistence', (operation) => { + it('silently no-ops when the storage capability is unavailable', async () => { + const bridgeMethod = jest + .fn() + .mockResolvedValue({ success: true }); + operation.installBridge(bridgeMethod); + + await expect(operation.invoke(service)).resolves.toBeUndefined(); + expect(bridgeMethod).not.toHaveBeenCalled(); + }); + + it('silently no-ops on an empty item list', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + const bridgeMethod = jest + .fn() + .mockResolvedValue({ success: true }); + operation.installBridge(bridgeMethod); + + await expect( + operation.invokeEmpty(service) + ).resolves.toBeUndefined(); + expect(bridgeMethod).not.toHaveBeenCalled(); + }); + + it('invokes the batch bridge method with the playlist and items', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + const bridgeMethod = jest + .fn() + .mockResolvedValue({ success: true }); + operation.installBridge(bridgeMethod); + + await expect(operation.invoke(service)).resolves.toBeUndefined(); + expect(bridgeMethod).toHaveBeenCalledWith( + 'playlist-1', + operation.items + ); + }); + + it.each([{ success: false }, {}, undefined])( + 'rejects a non-success result %#', + async (result) => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockResolvedValue(result)); + + await expect(operation.invoke(service)).rejects.toThrow( + 'did not succeed' + ); + } + ); + + it('rejects when the batch bridge method is unavailable', async () => { + runtimeCapabilities.supportsPlaybackPositionStorage = true; + + await expect(operation.invoke(service)).rejects.toThrow( + 'method is unavailable' + ); + }); + + it('propagates rejected IPC', async () => { + const error = new Error('database is locked'); + runtimeCapabilities.supportsPlaybackPositionStorage = true; + operation.installBridge(jest.fn().mockRejectedValue(error)); + + await expect(operation.invoke(service)).rejects.toBe(error); + }); + }); }); function createPosition( diff --git a/libs/services/src/lib/playback-position-runtime-bridge.service.ts b/libs/services/src/lib/playback-position-runtime-bridge.service.ts index ca5eea600..8456135d9 100644 --- a/libs/services/src/lib/playback-position-runtime-bridge.service.ts +++ b/libs/services/src/lib/playback-position-runtime-bridge.service.ts @@ -33,6 +33,17 @@ type PlaybackPositionElectronBridge = Partial<{ contentXtreamId: number, contentType: PlaybackPositionContentType ) => Promise<{ success: boolean }>; + dbSavePlaybackPositionsBatch: ( + playlistId: string, + items: PlaybackPositionData[] + ) => Promise<{ success: boolean }>; + dbClearPlaybackPositionsBatch: ( + playlistId: string, + items: { + contentXtreamId: number; + contentType: PlaybackPositionContentType; + }[] + ) => Promise<{ success: boolean }>; onPlaybackPositionUpdate: ( callback: (data: PlaybackPositionData) => void ) => () => void; @@ -197,6 +208,57 @@ export class PlaybackPositionRuntimeBridgeService { } } + async savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + if (!this.supportsStorage || items.length === 0) { + return; + } + + const bridge = this.bridge; + if (typeof bridge?.dbSavePlaybackPositionsBatch !== 'function') { + throw new Error( + 'Playback position batch save method is unavailable' + ); + } + + const result = await bridge.dbSavePlaybackPositionsBatch( + playlistId, + items + ); + if (result?.success !== true) { + throw new Error('Playback position batch save did not succeed'); + } + } + + async clearPlaybackPositionsBatch( + playlistId: string, + items: { + contentXtreamId: number; + contentType: PlaybackPositionContentType; + }[] + ): Promise { + if (!this.supportsStorage || items.length === 0) { + return; + } + + const bridge = this.bridge; + if (typeof bridge?.dbClearPlaybackPositionsBatch !== 'function') { + throw new Error( + 'Playback position batch clear method is unavailable' + ); + } + + const result = await bridge.dbClearPlaybackPositionsBatch( + playlistId, + items + ); + if (result?.success !== true) { + throw new Error('Playback position batch clear did not succeed'); + } + } + onPlaybackPositionUpdate( callback: (data: PlaybackPositionData) => void ): (() => void) | undefined { diff --git a/libs/services/src/lib/playback-position.service.spec.ts b/libs/services/src/lib/playback-position.service.spec.ts index 50be4e26c..556765e03 100644 --- a/libs/services/src/lib/playback-position.service.spec.ts +++ b/libs/services/src/lib/playback-position.service.spec.ts @@ -20,6 +20,8 @@ describe('PlaybackPositionService', () => { | 'getAllPlaybackPositions' | 'clearAllPlaybackPositions' | 'clearPlaybackPosition' + | 'savePlaybackPositionsBatch' + | 'clearPlaybackPositionsBatch' > >; @@ -32,6 +34,8 @@ describe('PlaybackPositionService', () => { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), + savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), + clearPlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined), }; injector = Injector.create({ @@ -150,6 +154,54 @@ describe('PlaybackPositionService', () => { service.clearPlaybackPosition('playlist-1', 100, 'vod') ).resolves.toBeUndefined(); }); + + it('delegates batch playback-position writes through the runtime bridge', async () => { + const items = [ + createPosition(), + createPosition({ + contentXtreamId: 101, + contentType: 'episode', + seriesXtreamId: 200, + }), + ]; + const clearItems: { + contentXtreamId: number; + contentType: 'vod' | 'episode'; + }[] = [ + { contentXtreamId: 100, contentType: 'vod' }, + { contentXtreamId: 101, contentType: 'episode' }, + ]; + + await service.savePlaybackPositionsBatch('playlist-1', items); + await service.clearPlaybackPositionsBatch('playlist-1', clearItems); + + expect(bridge.savePlaybackPositionsBatch).toHaveBeenCalledWith( + 'playlist-1', + items + ); + expect(bridge.clearPlaybackPositionsBatch).toHaveBeenCalledWith( + 'playlist-1', + clearItems + ); + }); + + it('propagates batch failures to the caller instead of swallowing them', async () => { + const saveError = new Error('batch save failed'); + const clearError = new Error('batch clear failed'); + bridge.savePlaybackPositionsBatch.mockRejectedValue(saveError); + bridge.clearPlaybackPositionsBatch.mockRejectedValue(clearError); + + await expect( + service.savePlaybackPositionsBatch('playlist-1', [ + createPosition(), + ]) + ).rejects.toBe(saveError); + await expect( + service.clearPlaybackPositionsBatch('playlist-1', [ + { contentXtreamId: 100, contentType: 'vod' }, + ]) + ).rejects.toBe(clearError); + }); }); function createPosition( diff --git a/libs/services/src/lib/playback-position.service.ts b/libs/services/src/lib/playback-position.service.ts index c70a17797..e88c232ba 100644 --- a/libs/services/src/lib/playback-position.service.ts +++ b/libs/services/src/lib/playback-position.service.ts @@ -84,6 +84,17 @@ export class PlaybackPositionService { } } + /** + * Failure-propagating read for cache refreshes: a swallowed error would + * surface as an authoritative empty list and let a transient IPC failure + * wipe an already-populated position cache. + */ + getAllPlaybackPositionsOrThrow( + playlistId: string + ): Promise { + return this.playbackPositionBridge.getAllPlaybackPositions(playlistId); + } + async clearAllPlaybackPositions(playlistId: string): Promise { try { await this.playbackPositionBridge.clearAllPlaybackPositions( @@ -109,4 +120,27 @@ export class PlaybackPositionService { console.error('Error clearing playback position:', error); } } + + // The batch methods deliberately propagate failures (no swallow): + // season-level bulk toggles must show an error and keep the UI state + // untouched when nothing was persisted. + savePlaybackPositionsBatch( + playlistId: string, + items: PlaybackPositionData[] + ): Promise { + return this.playbackPositionBridge.savePlaybackPositionsBatch( + playlistId, + items + ); + } + + clearPlaybackPositionsBatch( + playlistId: string, + items: { contentXtreamId: number; contentType: 'vod' | 'episode' }[] + ): Promise { + return this.playbackPositionBridge.clearPlaybackPositionsBatch( + playlistId, + items + ); + } } diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 824fb9969..b6e659fbf 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -92,6 +92,8 @@ describe('RuntimeCapabilitiesService', () => { dbGetAllPlaybackPositions: jest.fn(), dbClearAllPlaybackPositions: jest.fn(), dbClearPlaybackPosition: jest.fn(), + dbSavePlaybackPositionsBatch: jest.fn(), + dbClearPlaybackPositionsBatch: jest.fn(), onPlaybackPositionUpdate: jest.fn(), dbDeleteXtreamContent: jest.fn(), dbRestoreXtreamUserData: jest.fn(), @@ -468,6 +470,15 @@ describe('RuntimeCapabilitiesService', () => { dbClearPlaybackPosition: jest.fn(), }; + // The season-batch methods are part of the storage contract too. + expect(service.supportsPlaybackPositionStorage).toBe(false); + + testWindow.electron = { + ...testWindow.electron, + dbSavePlaybackPositionsBatch: jest.fn(), + dbClearPlaybackPositionsBatch: jest.fn(), + }; + expect(service.supportsPlaybackPositionStorage).toBe(true); expect(service.supportsPlaybackPositionUpdates).toBe(false); @@ -523,6 +534,8 @@ function createXtreamSqliteBridge(): Record { dbAddRecentItem: jest.fn(), dbClearAllPlaybackPositions: jest.fn(), dbClearPlaybackPosition: jest.fn(), + dbSavePlaybackPositionsBatch: jest.fn(), + dbClearPlaybackPositionsBatch: jest.fn(), dbClearPlaylistRecentItems: jest.fn(), dbCreatePlaylist: jest.fn(), dbDeletePlaylist: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 0ebb4ecf3..41da43352 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -5,6 +5,9 @@ export type RuntimeEnvironment = 'electron' | 'pwa'; type RuntimeElectronBridge = Partial; +// The full method set the position-storage layer may invoke — including the +// season-batch variants, so a bridge lacking them degrades to the in-memory +// path wholesale instead of throwing mid-action. const playbackPositionStorageMethods = [ 'dbSavePlaybackPosition', 'dbGetPlaybackPosition', @@ -13,6 +16,8 @@ const playbackPositionStorageMethods = [ 'dbGetAllPlaybackPositions', 'dbClearAllPlaybackPositions', 'dbClearPlaybackPosition', + 'dbSavePlaybackPositionsBatch', + 'dbClearPlaybackPositionsBatch', ]; @Injectable({ providedIn: 'root' }) diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 48893ba6b..d2c91a01e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1066,6 +1066,17 @@ export interface ElectronBridgeApi { contentXtreamId: number, contentType: ElectronBridgePlaybackContentType ) => Promise; + dbSavePlaybackPositionsBatch: ( + playlistId: string, + items: ElectronBridgePlaybackPositionInput[] + ) => Promise; + dbClearPlaybackPositionsBatch: ( + playlistId: string, + items: { + contentXtreamId: number; + contentType: ElectronBridgePlaybackContentType; + }[] + ) => Promise; onPlaybackPositionUpdate: ( callback: (data: PlaybackPositionData) => void ) => () => void; diff --git a/libs/ui/components/src/lib/season-container/episode-progress.util.ts b/libs/ui/components/src/lib/season-container/episode-progress.util.ts index 5df56ac62..706e5382a 100644 --- a/libs/ui/components/src/lib/season-container/episode-progress.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-progress.util.ts @@ -12,6 +12,16 @@ export function parseDuration(duration: string | number | undefined): number { return duration; } + // Providers report "45 min" (Stalker VOD) and "1h 30min" (Xtream) — + // the hour component must not be dropped when both are present. + const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/); + if (minutesMatch) { + return ( + parseInt(minutesMatch[1] ?? '0', 10) * 3600 + + parseInt(minutesMatch[2], 10) * 60 + ); + } + const parts = duration.split(':').map(Number); if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index c5eb1c7f2..0d2914d58 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -7,6 +7,11 @@ describe('episode-progress.util', () => { it('parses duration strings', () => { expect(parseDuration('01:00:30')).toBe(3630); expect(parseDuration('45:12')).toBe(2712); + // Stalker VOD episodes report minute strings; Xtream fixtures + // report compound hour/minute strings. + expect(parseDuration('45 min')).toBe(2700); + expect(parseDuration('1h 30min')).toBe(5400); + expect(parseDuration('2h 05min')).toBe(7500); expect(parseDuration(120)).toBe(120); expect(parseDuration(undefined)).toBe(0); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index 34cbe1f88..48c626719 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -4,9 +4,14 @@ [batchRunning]="downloadPresenter.batchRunning()" [seasonDisabled]="downloadPresenter.seasonDisabled()" [eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()" + [watchToggleVisible]="seasonWatchToggleVisible()" + [watchBatchRunning]="seasonWatchBatchRunning()" + [seasonFullyWatched]="selectedSeasonFullyWatched()" + [watchEligibleCount]="seasonWatchEligibleCount()" [viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()" [viewMode]="viewMode()" (downloadSeason)="downloadPresenter.enqueueSeason()" + (toggleSeasonWatched)="toggleSeasonWatched()" (viewModeChange)="setViewMode($event)" /> diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index a174b8680..46cf6225f 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -58,6 +58,7 @@ const EN_TRANSLATIONS = JSON.parse( const DOWNLOAD_TRANSLATIONS = { DOWNLOADS: EN_TRANSLATIONS['DOWNLOADS'], + XTREAM: EN_TRANSLATIONS['XTREAM'], }; function createEpisode( @@ -1131,4 +1132,191 @@ describe('SeasonContainerComponent', () => { { duration: 5000 } ); }); + + describe('season watched toggle', () => { + // The Stalker-style '45 min' format parses to 2700 seconds. + const PARSED_DURATION = 2700; + const parseableInfo = { + duration: '45 min', + plot: 'Pilot episode', + movie_image: 'https://example.com/poster.jpg', + }; + + const watchedPosition = (contentXtreamId: number) => ({ + contentXtreamId, + contentType: 'episode' as const, + seriesXtreamId: 20, + positionSeconds: 100, + durationSeconds: 100, + playlistId: 'playlist-1', + }); + + const toggleButton = (): HTMLButtonElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="toggle-season-watched"]' + ); + + const threeEpisodes = () => ({ + '1': [ + createEpisode({ info: parseableInfo }), + createEpisode({ + id: '102', + episode_num: 2, + info: parseableInfo, + }), + createEpisode({ + id: '103', + episode_num: 3, + info: parseableInfo, + }), + ], + }); + + it('emits watched requests only for unwatched episodes with parsed durations', () => { + const emitted: unknown[] = []; + component.seasonPlaybackToggleRequested.subscribe((request) => + emitted.push(request) + ); + setRequiredInputs(threeEpisodes()); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([[101, watchedPosition(101)]]) + ); + fixture.detectChanges(); + + const button = toggleButton(); + expect(button?.textContent).toContain( + 'Mark season as watched (2)' + ); + button?.click(); + + expect(emitted).toEqual([ + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 102, + nextPosition: expect.objectContaining({ + contentXtreamId: 102, + contentType: 'episode', + seriesXtreamId: 20, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: PARSED_DURATION, + durationSeconds: PARSED_DURATION, + playlistId: 'playlist-1', + }), + }, + { + contentXtreamId: 103, + nextPosition: expect.objectContaining({ + contentXtreamId: 103, + episodeNumber: 3, + }), + }, + ], + }, + ]); + }); + + it('flips to unwatch and emits null positions for every episode when the season is fully watched', () => { + const emitted: { + markWatched: boolean; + requests: { contentXtreamId: number; nextPosition: unknown }[]; + }[] = []; + component.seasonPlaybackToggleRequested.subscribe((request) => + emitted.push(request) + ); + setRequiredInputs(threeEpisodes()); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, watchedPosition(101)], + [102, watchedPosition(102)], + [103, watchedPosition(103)], + ]) + ); + fixture.detectChanges(); + + const button = toggleButton(); + expect(button?.textContent).toContain('Mark season as unwatched'); + expect(button?.querySelector('mat-icon')?.textContent).toContain( + 'remove_done' + ); + button?.click(); + + expect(emitted).toHaveLength(1); + expect(emitted[0].markWatched).toBe(false); + expect( + emitted[0].requests.map((request) => [ + request.contentXtreamId, + request.nextPosition, + ]) + ).toEqual([ + [101, null], + [102, null], + [103, null], + ]); + }); + + it('never bulk-marks the playing episode and disables an empty action', () => { + const emitted: { + requests: { contentXtreamId: number }[]; + }[] = []; + component.seasonPlaybackToggleRequested.subscribe((request) => + emitted.push(request) + ); + setRequiredInputs(threeEpisodes()); + fixture.componentRef.setInput('playingEpisodeId', 102); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([[101, watchedPosition(101)]]) + ); + fixture.detectChanges(); + + const button = toggleButton(); + // 103 is the only markable episode: 101 watched, 102 playing. + expect(button?.textContent).toContain( + 'Mark season as watched (1)' + ); + button?.click(); + expect( + emitted[0].requests.map((item) => item.contentXtreamId) + ).toEqual([103]); + + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, watchedPosition(101)], + [103, watchedPosition(103)], + ]) + ); + fixture.detectChanges(); + expect(toggleButton()?.disabled).toBe(true); + }); + + it('disables the toggle and reports busy while the host batch is running', () => { + setRequiredInputs(threeEpisodes()); + fixture.componentRef.setInput('seasonWatchBatchRunning', true); + fixture.detectChanges(); + + const button = toggleButton(); + expect(button?.disabled).toBe(true); + expect(button?.getAttribute('aria-busy')).toBe('true'); + expect(button?.querySelector('mat-spinner')).not.toBeNull(); + }); + + it('hides the toggle without a playlist id and on empty seasons', () => { + setRequiredInputs(threeEpisodes()); + fixture.componentRef.setInput('playlistId', ''); + fixture.detectChanges(); + expect(toggleButton()).toBeNull(); + + fixture.componentRef.setInput('playlistId', 'playlist-1'); + fixture.componentRef.setInput('seasons', {}); + fixture.detectChanges(); + expect(toggleButton()).toBeNull(); + }); + }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index b885ecfe0..dcfda01ab 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -35,23 +35,28 @@ import { EpisodeInfoDialogComponent, buildEpisodeInfoDialogData, } from './episode-info-dialog.component'; -import { - formatEpisodePositionText, - parseDuration, -} from './episode-progress.util'; +import { formatEpisodePositionText } from './episode-progress.util'; import { SeasonDownloadPresenter } from './season-download-presenter'; import { type EpisodeViewMode, SeasonHeaderComponent, } from './season-header.component'; import { SeasonTabsComponent } from './season-tabs.component'; +import { + type SeasonContainerPlaybackToggleRequest, + type SeasonContainerSeasonPlaybackToggleRequest, + buildSeasonWatchToggleRequest, + buildWatchedEpisodePosition, + listMarkableEpisodes, + resolveEpisodeInfo, +} from './season-watch-toggle.util'; const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; -export interface SeasonContainerPlaybackToggleRequest { - contentXtreamId: number; - nextPosition: PlaybackPositionData | null; -} +export type { + SeasonContainerPlaybackToggleRequest, + SeasonContainerSeasonPlaybackToggleRequest, +} from './season-watch-toggle.util'; @Component({ selector: 'app-season-container', @@ -94,10 +99,14 @@ export class SeasonContainerComponent implements OnInit { readonly playingEpisodeId = input(null); /** Per-season descriptions (TMDB/provider), keyed by season key. */ readonly seasonDescriptions = input | null>(null); + /** True while a host is persisting a season-level watched toggle. */ + readonly seasonWatchBatchRunning = input(false); readonly episodeClicked = output(); readonly playbackToggleRequested = output(); + readonly seasonPlaybackToggleRequested = + output(); readonly seasonSelected = output(); readonly viewMode = signal('grid'); @@ -143,6 +152,51 @@ 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('|')}::${ @@ -294,35 +348,44 @@ export class SeasonContainerComponent implements OnInit { return; } - const info = this.getEpisodeInfo(episode); - const duration = - info?.duration_secs || parseDuration(info?.duration) || 1; - this.playbackToggleRequested.emit({ contentXtreamId, - nextPosition: { - contentXtreamId, - contentType: 'episode', - seriesXtreamId: this.seriesId(), - seasonNumber: Number( - episode.season || this.selectedSeason() || 1 - ), - episodeNumber: Number(episode.episode_num || 1), - positionSeconds: duration, - durationSeconds: duration, + nextPosition: buildWatchedEpisodePosition({ + episode, + seriesId: this.seriesId(), playlistId: this.playlistId(), - updatedAt: new Date().toISOString(), - }, + fallbackSeasonKey: this.selectedSeason(), + }), }); } + 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 { - if (Array.isArray(episode.info) || !episode.info) { - return undefined; - } - return episode.info; + return resolveEpisodeInfo(episode); } isEpisodeWatched(episode: XtreamSerieEpisode): boolean { diff --git a/libs/ui/components/src/lib/season-container/season-header.component.html b/libs/ui/components/src/lib/season-container/season-header.component.html index 9974dbd5e..3987e9e2d 100644 --- a/libs/ui/components/src/lib/season-container/season-header.component.html +++ b/libs/ui/components/src/lib/season-container/season-header.component.html @@ -4,8 +4,46 @@ {{ 'PORTALS.SEASONS_AND_EPISODES' | translate }} - @if (downloadVisible() || viewToggleVisible()) { + @if (downloadVisible() || watchToggleVisible() || viewToggleVisible()) {
+ @if (watchToggleVisible()) { + + } @if (downloadVisible()) {