From 760099358b5e652c2db7f1c12d92bfee5b6d37f7 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:09:31 +0200 Subject: [PATCH] feat(downloads): redesign download manager (#1313) * docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes --- .changes/downloads-manager-mvp.md | 11 + CLAUDE.md | 38 +- .../src/downloads.e2e-support.ts | 204 +++ .../electron-backend-e2e/src/downloads.e2e.ts | 1083 ++++++++++-- .../src/app/api/main.preload.spec.ts | 23 +- .../src/app/api/main.preload.ts | 12 + .../playlist-delete-performance.spec.ts | 14 +- .../operations/playlist.operations.ts | 47 +- .../events/database/download-artwork-url.ts | 191 +++ .../download-file-availability.spec.ts | 105 ++ .../database/download-file-availability.ts | 169 ++ .../download-metadata-input-safety.ts | 113 ++ .../download-metadata-snapshot.spec.ts | 437 +++++ .../database/download-metadata-snapshot.ts | 257 +++ .../database/download-metadata-update.ts | 146 ++ .../database/download-metadata.events.spec.ts | 548 ++++++ .../database/download-redownload.spec.ts | 201 +++ .../events/database/download-redownload.ts | 112 ++ .../events/database/download-requests.spec.ts | 410 ++++- .../app/events/database/download-requests.ts | 82 +- .../events/database/downloads-actions.spec.ts | 126 +- ...downloads-file-availability.events.spec.ts | 214 +++ .../app/events/database/downloads.events.ts | 61 +- .../events/database/downloads.test-helpers.ts | 30 +- apps/web/src/app/app.routes.ts | 7 + apps/web/src/assets/i18n/ar.json | 71 +- apps/web/src/assets/i18n/ary.json | 71 +- apps/web/src/assets/i18n/by.json | 71 +- apps/web/src/assets/i18n/de.json | 71 +- apps/web/src/assets/i18n/el.json | 71 +- apps/web/src/assets/i18n/en.json | 71 +- apps/web/src/assets/i18n/es.json | 71 +- apps/web/src/assets/i18n/fr.json | 71 +- apps/web/src/assets/i18n/hu.json | 71 +- apps/web/src/assets/i18n/it.json | 71 +- apps/web/src/assets/i18n/ja.json | 71 +- apps/web/src/assets/i18n/ko.json | 71 +- apps/web/src/assets/i18n/nl.json | 71 +- apps/web/src/assets/i18n/pl.json | 71 +- apps/web/src/assets/i18n/pt.json | 71 +- apps/web/src/assets/i18n/ru.json | 71 +- apps/web/src/assets/i18n/tr.json | 71 +- apps/web/src/assets/i18n/zh.json | 71 +- apps/web/src/assets/i18n/zhtw.json | 71 +- docs/architecture/download-manager.md | 157 +- docs/architecture/portal-detail-navigation.md | 43 + docs/architecture/sqlite-db-worker.md | 12 +- docs/architecture/stalker-portal.md | 19 + ...7-30-download-manager-detail-navigation.md | 462 ++++++ ...7-30-download-manager-file-availability.md | 908 ++++++++++ .../plans/2026-07-30-download-manager-mvp.md | 1343 +++++++++++++++ ...-07-31-download-manager-offline-details.md | 1469 +++++++++++++++++ ...wnload-manager-file-availability-design.md | 264 +++ .../2026-07-30-download-manager-mvp-design.md | 515 ++++++ ...download-manager-offline-details-design.md | 344 ++++ .../category-content-view.component.html | 24 +- .../category-content-view.component.spec.ts | 103 +- .../category-content-view.component.ts | 41 + libs/portal/downloads/feature/jest.config.ts | 21 + libs/portal/downloads/feature/project.json | 8 + libs/portal/downloads/feature/src/index.ts | 1 + .../feature/src/lib/download-actions.ts | 20 + ...ownload-library-navigation.service.spec.ts | 757 +++++++++ .../download-library-navigation.service.ts | 269 +++ .../src/lib/download-library.component.html | 404 +++++ .../src/lib/download-library.component.scss | 249 +++ .../lib/download-library.component.spec.ts | 543 ++++++ .../src/lib/download-library.component.ts | 126 ++ .../lib/download-library.viewmodel.spec.ts | 202 +++ .../src/lib/download-library.viewmodel.ts | 261 +++ .../lib/download-manager-actions.service.ts | 210 +++ .../lib/download-manager.viewmodel.spec.ts | 1080 ++++++++++++ .../src/lib/download-manager.viewmodel.ts | 240 +++ .../src/lib/download-queue.component.html | 388 +++++ .../src/lib/download-queue.component.scss | 342 ++++ .../src/lib/download-queue.component.spec.ts | 655 ++++++++ .../src/lib/download-queue.component.ts | 197 +++ .../download-source-menu-header.component.ts | 48 + .../downloaded-series-dialog.component.html | 117 ++ .../downloaded-series-dialog.component.scss | 158 ++ ...downloaded-series-dialog.component.spec.ts | 245 +++ .../lib/downloaded-series-dialog.component.ts | 99 ++ .../feature/src/lib/downloads.component.html | 653 +++----- .../feature/src/lib/downloads.component.scss | 921 +++++------ .../src/lib/downloads.component.spec.ts | 947 +++++++++++ .../feature/src/lib/downloads.component.ts | 712 +++----- .../download-metadata-refresh.spec.ts | 60 + .../download-metadata-refresh.ts | 262 +++ .../download-metadata-tmdb-seeds.ts | 121 ++ .../download-metadata.mapper.spec.ts | 389 +++++ .../download-metadata.mapper.ts | 279 ++++ .../download-offline-detail.component.html | 499 ++++++ .../download-offline-detail.component.scss | 203 +++ .../download-offline-detail.component.spec.ts | 1107 +++++++++++++ .../download-offline-detail.component.ts | 290 ++++ ...wnload-offline-detail.presentation.spec.ts | 27 + .../download-offline-detail.presentation.ts | 195 +++ .../download-offline-detail.viewmodel.spec.ts | 789 +++++++++ .../download-offline-detail.viewmodel.ts | 391 +++++ ...wnload-offline-file-coordinator.service.ts | 175 ++ ...oad-offline-metadata-resolution.service.ts | 80 + .../download-offline-metadata.service.spec.ts | 902 ++++++++++ .../download-offline-metadata.service.ts | 302 ++++ ...ad-offline-provider-coordinator.service.ts | 132 ++ .../download-offline-route-context.ts | 32 + ...wnload-offline-route-navigation.service.ts | 44 + ...wnload-offline-season-selection.service.ts | 128 ++ .../lib/stalker-download-recent.matcher.ts | 71 + .../downloads/feature/src/test-setup.ts | 6 + .../downloads/feature/tsconfig.spec.json | 17 + libs/portal/shared/util/src/index.ts | 2 + .../download-metadata-snapshot.spec.ts | 205 +++ .../downloads/download-metadata-snapshot.ts | 319 ++++ .../navigation/provider-detail-mode.spec.ts | 20 + .../lib/navigation/provider-detail-mode.ts | 13 + .../stalker-catalog-detail.component.html | 10 +- .../stalker-catalog-detail.component.spec.ts | 181 ++ .../stalker-catalog-detail.component.ts | 6 + .../stalker-vod-download.spec.ts | 123 ++ .../stalker-vod-download.ts | 90 +- .../feature/src/lib/stalker-feature.routes.ts | 9 + .../stalker-inline-detail.component.html | 12 +- .../stalker-inline-detail.component.spec.ts | 93 ++ .../stalker-inline-detail.component.ts | 6 +- .../stalker-series-download-metadata.spec.ts | 58 + .../stalker-series-download-metadata.ts | 102 ++ .../stalker-series-view.component.html | 38 +- .../stalker-series-view.component.spec.ts | 119 +- .../stalker-series-view.component.ts | 124 +- .../lib/stores/xtream-tmdb-enrichment.spec.ts | 99 ++ .../src/lib/stores/xtream-tmdb-enrichment.ts | 29 +- .../src/lib/stores/xtream.store.spec.ts | 109 ++ .../src/lib/stores/xtream.store.ts | 15 +- .../serial-details.component.html | 32 +- .../serial-details.component.spec.ts | 62 +- .../serial-details.component.ts | 22 +- .../serial-download-metadata.spec.ts | 61 + .../serial-download-metadata.ts | 66 + .../vod-details-downloads.service.ts | 77 +- .../vod-details-playback.service.spec.ts | 101 +- .../vod-details-playback.service.ts | 18 +- .../vod-details-route-playback.spec.ts | 252 ++- .../vod-details-route.actions.spec.ts | 338 +++- .../vod-details-route.component.html | 205 ++- .../vod-details-route.component.spec.ts | 4 +- .../vod-details-route.component.ts | 71 +- .../vod-details/vod-details-route.harness.ts | 34 +- .../feature/src/lib/xtream-feature.routes.ts | 21 +- .../src/lib/downloads-metadata-update.ts | 29 + libs/services/src/lib/downloads.models.ts | 36 + .../src/lib/downloads.service.spec.ts | 322 +++- libs/services/src/lib/downloads.service.ts | 160 +- libs/services/src/lib/downloads.utils.ts | 11 + .../lib/runtime-capabilities.service.spec.ts | 8 + .../src/lib/runtime-capabilities.service.ts | 4 +- .../database/src/lib/connection.spec.ts | 181 +- libs/shared/database/src/lib/connection.ts | 52 +- libs/shared/database/src/lib/schema.ts | 13 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/download-metadata.interface.ts | 37 + .../src/lib/electron-api.interface.ts | 25 +- .../content-hero/content-hero.component.html | 31 +- .../content-hero/content-hero.component.scss | 8 + .../content-hero.component.spec.ts | 90 +- .../content-hero/content-hero.component.ts | 30 +- .../portal-detail-shell.component.html | 1 + .../portal-detail-shell.component.spec.ts | 4 + .../portal-detail-shell.component.ts | 1 + .../season-container/episode-download.util.ts | 65 +- .../season-container/episode-utils.spec.ts | 88 +- .../season-container.component.html | 13 +- .../season-container.component.spec.ts | 69 +- .../season-container.component.ts | 18 +- .../vod-details/vod-details.component.html | 97 +- .../vod-details/vod-details.component.spec.ts | 420 +++++ .../lib/vod-details/vod-details.component.ts | 66 +- .../workspace-shell-header.component.html | 18 +- .../workspace-shell-header.component.scss | 22 + .../workspace-shell-header.component.spec.ts | 82 +- .../workspace-shell-header.component.ts | 6 +- .../services/workspace-shell.facade.spec.ts | 15 + .../services/workspace-shell.facade.ts | 5 +- .../workspace-shell.component.html | 17 +- .../workspace-shell.component.spec.ts | 16 +- .../workspace-shell-route.utils.spec.ts | 197 ++- .../navigation/workspace-shell-route.utils.ts | 41 +- tools/coverage/coverage-policy.json | 12 +- tools/eslint/max-lines-baseline.mjs | 1 - 188 files changed, 31245 insertions(+), 2281 deletions(-) create mode 100644 .changes/downloads-manager-mvp.md create mode 100644 apps/electron-backend-e2e/src/downloads.e2e-support.ts create mode 100644 apps/electron-backend/src/app/events/database/download-artwork-url.ts create mode 100644 apps/electron-backend/src/app/events/database/download-file-availability.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-file-availability.ts create mode 100644 apps/electron-backend/src/app/events/database/download-metadata-input-safety.ts create mode 100644 apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts create mode 100644 apps/electron-backend/src/app/events/database/download-metadata-update.ts create mode 100644 apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-redownload.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/download-redownload.ts create mode 100644 apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts create mode 100644 docs/superpowers/plans/2026-07-30-download-manager-detail-navigation.md create mode 100644 docs/superpowers/plans/2026-07-30-download-manager-file-availability.md create mode 100644 docs/superpowers/plans/2026-07-30-download-manager-mvp.md create mode 100644 docs/superpowers/plans/2026-07-31-download-manager-offline-details.md create mode 100644 docs/superpowers/specs/2026-07-30-download-manager-file-availability-design.md create mode 100644 docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md create mode 100644 docs/superpowers/specs/2026-07-31-download-manager-offline-details-design.md create mode 100644 libs/portal/downloads/feature/jest.config.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-actions.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library.component.html create mode 100644 libs/portal/downloads/feature/src/lib/download-library.component.scss create mode 100644 libs/portal/downloads/feature/src/lib/download-library.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library.component.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.html create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.scss create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts create mode 100644 libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html create mode 100644 libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss create mode 100644 libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts create mode 100644 libs/portal/downloads/feature/src/lib/downloads.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/stalker-download-recent.matcher.ts create mode 100644 libs/portal/downloads/feature/src/test-setup.ts create mode 100644 libs/portal/downloads/feature/tsconfig.spec.json create mode 100644 libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts create mode 100644 libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts create mode 100644 libs/services/src/lib/downloads-metadata-update.ts create mode 100644 libs/services/src/lib/downloads.models.ts create mode 100644 libs/services/src/lib/downloads.utils.ts create mode 100644 libs/shared/interfaces/src/lib/download-metadata.interface.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts diff --git a/.changes/downloads-manager-mvp.md b/.changes/downloads-manager-mvp.md new file mode 100644 index 000000000..30613e4d9 --- /dev/null +++ b/.changes/downloads-manager-mvp.md @@ -0,0 +1,11 @@ +--- +type: feature +area: downloads +--- + +Downloads now separate active transfers from a grouped offline library, move +missing files to Needs attention with Download again, and open movies and +series in focused offline details with saved metadata. Series show only local +episodes; View in portal returns to provider playback when the source can be +recovered. +Completed-file checks stay responsive on slow or unavailable storage. diff --git a/CLAUDE.md b/CLAUDE.md index 531033b7f..120fb100e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -491,7 +491,11 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. - Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` - Global collections: `/workspace/global-favorites`, `/workspace/global-recent` - Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`) -- Downloads: `/workspace/downloads` +- Downloads: `/workspace/downloads` with focused + `/workspace/downloads/:downloadId`; source-scoped equivalents are + `/workspace/xtreams/:id/downloads/:downloadId` and + `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide + the workspace context panel. - Settings: `/workspace/settings` (`/settings` redirects there) **Service Architecture** (Factory Pattern): @@ -905,6 +909,38 @@ engine` (restart required) or chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP keyboard shortcut, and Embedded MPV popup/native support are out of scope. +**Download Manager**: + +- The desktop-only manager shares one global download store across the global, + Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series + cards use the global Small/Medium/Large cover-grid tokens; missing completed + files move to Needs attention instead of remaining in Ready to watch. +- Ready movie and grouped-series cards open a focused local detail. Movies play + the finalized local file; series list only locally available episode rows and + every episode action targets its own downloaded file. Focused routes disable + route search and use `contextPanel: 'none'`. +- Downloads capture a versioned metadata snapshot from the rendered Xtream or + Stalker movie/episode detail at start time, including already-merged TMDB + fields. Legacy, sparse, stale, or wrong-language snapshots are safely + backfilled from row/provider metadata and optional TMDB enrichment when the + focused detail opens. +- `View in portal` resolves a concrete Xtream category/item route. Stalker + accepts a recently-viewed shape only when its raw movie/series mode matches + the download, and prefers an exact numeric category from the download + snapshot. Without that shape, only a movie carrying an exact category can + form a metadata-only target; unproven episode and legacy-movie handoffs stay + unavailable. The normal detail uses one-shot `provider-only` presentation: + it exposes provider content/playback it can resolve while hiding + Offline/local/download actions. +- Download rows and local files survive source deletion. The global offline + library remains visible with no playlists; only provider handoff is disabled + until the source exists again. +- If a finalized file disappears while a focused detail is open, the + authoritative download list refreshes and returns to the manager. A failed + redirect leaves an actionable missing-file state with Back and Retry. +- Canonical contract: `docs/architecture/download-manager.md`; provider handoff: + `docs/architecture/portal-detail-navigation.md`. + **VOD/Series Detail Pages (two-state layout)**: - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) diff --git a/apps/electron-backend-e2e/src/downloads.e2e-support.ts b/apps/electron-backend-e2e/src/downloads.e2e-support.ts new file mode 100644 index 000000000..fc7997f78 --- /dev/null +++ b/apps/electron-backend-e2e/src/downloads.e2e-support.ts @@ -0,0 +1,204 @@ +import { createServer } from 'http'; +import type { AddressInfo } from 'net'; +import type { Page } from '@playwright/test'; +import { expect, type LaunchedElectronApp } from './electron-test-fixtures'; +import { + getStalkerTitle, + type StalkerContentItem, +} from './portal-mock-fixtures'; + +interface DownloadSeed { + contentType: 'episode' | 'vod'; + downloadFolder: string; + episodeNumber?: number; + playlistId: string; + seasonNumber?: number; + seriesXtreamId?: number; + title: string; + url: string; + xtreamId: number; +} + +interface RangeServerRequest { + ifRange?: string; + range?: string; +} + +interface ThrottledRangeServer { + close: () => Promise; + payload: Buffer; + requests: RangeServerRequest[]; + url: string; +} + +interface TruncatedDownloadServer { + close: () => Promise; + payload: Buffer; + totalBytes: number; + url: string; +} + +export const RANGE_SERVER_ETAG = '"e2e-range-etag"'; +const downloadPlayCaptureKey = '__iptvnatorE2eDownloadPlayPaths'; + +export function getStalkerSeriesDownloadTarget( + item: StalkerContentItem | undefined +): { id: number; title: string } { + const id = Number( + String(item?.id ?? '') + .split(':')[0] + .trim() + ); + const title = item ? getStalkerTitle(item) : ''; + if (!Number.isSafeInteger(id) || id <= 0 || !title) { + throw new Error('Stalker series fixture returned an invalid target.'); + } + return { id, title }; +} + +export async function startDownload( + page: Page, + seed: DownloadSeed +): Promise { + const result = await page.evaluate( + async (payload) => window.electron?.downloadsStart?.(payload), + seed + ); + expect(result?.error ?? null).toBeNull(); + expect(result?.id).toEqual(expect.any(Number)); + if (typeof result?.id !== 'number') { + throw new Error(`Download did not return an id: ${seed.title}`); + } + return result.id; +} + +export async function installDownloadPlayCapture( + app: LaunchedElectronApp +): Promise { + await app.electronApp.evaluate(({ ipcMain }, captureKey) => { + const globalRef = globalThis as typeof globalThis & + Record; + globalRef[captureKey] = []; + ipcMain.removeHandler('DOWNLOADS_PLAY_FILE'); + ipcMain.handle( + 'DOWNLOADS_PLAY_FILE', + async (_event, filePath: string) => { + globalRef[captureKey]?.push(filePath); + return { success: true }; + } + ); + }, downloadPlayCaptureKey); +} + +export async function getDownloadPlayPaths( + app: LaunchedElectronApp +): Promise { + return app.electronApp.evaluate((_, captureKey) => { + const globalRef = globalThis as typeof globalThis & + Record; + return globalRef[captureKey] ?? []; + }, downloadPlayCaptureKey); +} + +/** + * Serves a payload slowly on the first (full) request so the UI has a wide + * window to pause mid-transfer, and answers Range requests with an immediate + * 206 so the resumed transfer finishes fast. Records Range/If-Range headers. + */ +export async function createThrottledRangeServer( + chunkIntervalMs = 150 +): Promise { + const payload = Buffer.from( + Array.from({ length: 96 * 1024 }, (_, index) => + String(index % 10) + ).join('') + ); + const requests: RangeServerRequest[] = []; + + const server = createServer((req, res) => { + const range = req.headers.range; + const ifRange = req.headers['if-range']; + requests.push({ + ifRange: typeof ifRange === 'string' ? ifRange : undefined, + range: typeof range === 'string' ? range : undefined, + }); + + const offset = range + ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) + : 0; + if (range && Number.isFinite(offset)) { + res.writeHead(206, { + 'Content-Length': payload.length - offset, + 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + res.end(payload.subarray(offset)); + return; + } + + res.writeHead(200, { + 'Content-Length': payload.length, + 'Content-Type': 'video/mp4', + ETag: RANGE_SERVER_ETAG, + }); + // First 16 KiB immediately, then a trickle: the transfer stays alive + // for tens of seconds unless it is paused or resumed via Range. + let sent = 16 * 1024; + res.write(payload.subarray(0, sent)); + const timer = setInterval(() => { + if (sent >= payload.length) { + clearInterval(timer); + res.end(); + return; + } + res.write(payload.subarray(sent, sent + 2 * 1024)); + sent += 2 * 1024; + }, chunkIntervalMs); + res.on('close', () => clearInterval(timer)); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + payload, + requests, + url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`, + }; +} + +/** + * Ends a chunked response cleanly while Content-Range advertises a larger + * representation. The runtime therefore retains the valid .part for a Range + * retry instead of treating it as an unusable transport failure. + */ +export async function createTruncatedDownloadServer(): Promise { + const payload = Buffer.alloc(16 * 1024, 7); + const totalBytes = payload.length * 4; + const server = createServer((_req, res) => { + res.writeHead(200, { + 'Content-Range': `bytes 0-${payload.length - 1}/${totalBytes}`, + 'Content-Type': 'video/mp4', + ETag: '"e2e-truncated-etag"', + }); + res.end(payload); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + payload, + totalBytes, + url: `http://127.0.0.1:${port}/media/e2e-truncated-movie.mp4`, + }; +} diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index fe92a4208..bb3d2e47f 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -1,108 +1,157 @@ -import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; -import { createServer } from 'http'; -import type { AddressInfo } from 'net'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, +} from 'fs'; import { join } from 'path'; import type { Page } from '@playwright/test'; import { + addStalkerPortal, addXtreamPortal, closeElectronApp, createMutableTextServer, expect, launchElectronApp, + openSettings, resetMockServers, + saveSettings, test, + waitForStalkerCatalog, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { fetchStalkerCategoryFixture } from './portal-mock-fixtures'; +import { + createThrottledRangeServer, + createTruncatedDownloadServer, + getDownloadPlayPaths, + getStalkerSeriesDownloadTarget, + installDownloadPlayCapture, + RANGE_SERVER_ETAG, + startDownload, +} from './downloads.e2e-support'; async function openDownloadsPage(page: Page): Promise { await page.getByRole('button', { name: 'Open downloads' }).click(); await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } -interface RangeServerRequest { - ifRange?: string; - range?: string; +async function navigateWithinWorkspace( + page: Page, + path: string +): Promise { + const targetPathname = await page.evaluate((target) => { + const targetUrl = new URL(window.location.href); + const workspaceIndex = targetUrl.pathname.lastIndexOf('/workspace'); + const rendererPath = + workspaceIndex >= 0 + ? targetUrl.pathname.slice(0, workspaceIndex) + : targetUrl.pathname.replace(/\/$/, ''); + + targetUrl.pathname = `${rendererPath}${target}`; + targetUrl.search = ''; + targetUrl.hash = ''; + window.history.pushState(null, '', targetUrl); + window.dispatchEvent(new PopStateEvent('popstate')); + return targetUrl.pathname; + }, path); + await page.waitForURL((url) => url.pathname === targetPathname); } -interface ThrottledRangeServer { - close: () => Promise; - payload: Buffer; - requests: RangeServerRequest[]; - url: string; +async function getPlaylistId(page: Page, title: string): Promise { + const playlists = await page.evaluate( + async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? [] + ); + const playlist = playlists.find((entry) => entry.title === title); + expect(playlist, `playlist "${title}" should exist`).toBeDefined(); + return playlist?._id ?? ''; } -const RANGE_SERVER_ETAG = '"e2e-range-etag"'; - -/** - * Serves a payload slowly on the first (full) request so the UI has a wide - * window to pause mid-transfer, and answers Range requests with an immediate - * 206 so the resumed transfer finishes fast. Records Range/If-Range headers. - */ -async function createThrottledRangeServer(): Promise { - const payload = Buffer.from( - Array.from({ length: 96 * 1024 }, (_, index) => - String(index % 10) - ).join('') +async function getSeriesTarget( + page: Page, + playlistId: string +): Promise<{ categoryId: number; xtreamId: number }> { + const target = await page.evaluate( + async (sourceId) => + (await window.electron?.dbGetContent?.(sourceId, 'series'))?.[0] ?? + null, + playlistId ); - const requests: RangeServerRequest[] = []; - - const server = createServer((req, res) => { - const range = req.headers.range; - const ifRange = req.headers['if-range']; - requests.push({ - ifRange: typeof ifRange === 'string' ? ifRange : undefined, - range: typeof range === 'string' ? range : undefined, - }); - - const offset = range - ? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN) - : 0; - if (range && Number.isFinite(offset)) { - res.writeHead(206, { - 'Content-Length': payload.length - offset, - 'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`, - 'Content-Type': 'video/mp4', - ETag: RANGE_SERVER_ETAG, - }); - res.end(payload.subarray(offset)); - return; - } - - res.writeHead(200, { - 'Content-Length': payload.length, - 'Content-Type': 'video/mp4', - ETag: RANGE_SERVER_ETAG, - }); - // First 16 KiB immediately, then a trickle: the transfer stays alive - // for tens of seconds unless it is paused or resumed via Range. - let sent = 16 * 1024; - res.write(payload.subarray(0, sent)); - const timer = setInterval(() => { - if (sent >= payload.length) { - clearInterval(timer); - res.end(); - return; - } - res.write(payload.subarray(sent, sent + 2 * 1024)); - sent += 2 * 1024; - }, 150); - res.on('close', () => clearInterval(timer)); - }); - - await new Promise((resolve) => - server.listen(0, '127.0.0.1', resolve) + expect(target, 'playlist should have an imported series target').not.toBe( + null ); - const { port } = server.address() as AddressInfo; - + if (!target) { + throw new Error('Series target was not imported'); + } + expect(target.xtream_id).toBeGreaterThan(0); + expect(target.category_id).toBeGreaterThan(0); return { - close: () => - new Promise((resolve) => server.close(() => resolve())), - payload, - requests, - url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`, + categoryId: target.category_id, + xtreamId: target.xtream_id, }; } +async function getVodTarget( + page: Page, + playlistId: string +): Promise<{ categoryId: number; xtreamId: number }> { + const target = await page.evaluate( + async (sourceId) => + (await window.electron?.dbGetContent?.(sourceId, 'movie'))?.[0] ?? + null, + playlistId + ); + expect(target, 'playlist should have an imported VOD target').not.toBe( + null + ); + if (!target) { + throw new Error('VOD target was not imported'); + } + expect(target.xtream_id).toBeGreaterThan(0); + expect(target.category_id).toBeGreaterThan(0); + return { + categoryId: target.category_id, + xtreamId: target.xtream_id, + }; +} + +async function selectDownloadCoverSize( + page: Page, + size: 'small' | 'medium' | 'large' +): Promise { + await openSettings(page); + await page.getByTestId(`cover-size-${size}`).click(); + await expect + .poll(() => + page.evaluate( + () => document.documentElement.dataset.coverSize ?? null + ) + ) + .toBe(size); + await saveSettings(page); + await openDownloadsPage(page); +} + +async function measureLibraryCard( + page: Page +): Promise<{ gap: number; width: number }> { + return page + .locator('article[data-test-id^="download-library-"]') + .first() + .evaluate((card) => { + const grid = card.parentElement; + if (!grid) { + throw new Error('Download library card has no grid parent'); + } + return { + gap: Number.parseFloat(getComputedStyle(grid).columnGap), + width: card.getBoundingClientRect().width, + }; + }); +} + /** * On a cold profile the renderer can query SQLite while the DB worker is * still creating tables ("database is locked" / "no such table" on slow CI @@ -147,9 +196,7 @@ test.describe('Electron Downloads', () => { // First launch runs the IndexedDB→SQLite playlist migration before // the playlists query resolves, so allow extra time here. await expect( - app.mainWindow.getByText( - 'Add a playlist to start downloading' - ) + app.mainWindow.getByText('Add a playlist to start downloading') ).toBeVisible({ timeout: 20000 }); } finally { await closeElectronApp(app); @@ -198,55 +245,844 @@ test.describe('Electron Downloads', () => { .getByRole('button', { name: 'Change Folder' }) .click(); await expect( - app.mainWindow.locator('.downloads__folder-inline-path') + app.mainWindow.getByTestId('downloads-folder') ).toContainText('e2e-downloads'); // Enqueue a download through the same renderer bridge the app uses. + const playlistId = await getPlaylistId( + app.mainWindow, + 'Download Portal' + ); const startResult = await app.mainWindow.evaluate( - async ({ url, folder }) => + async ({ url, folder, sourceId }) => window.electron?.downloadsStart?.({ - playlistId: 'e2e-playlist', + playlistId: sourceId, xtreamId: 4242, contentType: 'vod', title: 'E2E Movie', url, downloadFolder: folder, }), - { url: fileServer.resourceUrl, folder: downloadsDir } + { + url: fileServer.resourceUrl, + folder: downloadsDir, + sourceId: playlistId, + } ); expect(startResult?.error ?? null).toBeNull(); + expect(startResult?.id).toEqual(expect.any(Number)); - const item = app.mainWindow.locator('.downloads__item'); - await expect(item).toHaveCount(1, { timeout: 20000 }); - await expect( - item.locator('.downloads__item-title-text') - ).toHaveText('E2E Movie'); - await expect(item.locator('.downloads__item-status')).toContainText( - 'Completed', - { timeout: 20000 } + const card = app.mainWindow.getByTestId( + `download-library-movie-${startResult?.id}` ); + await expect(card).toBeVisible({ timeout: 20000 }); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Ready to watch', + }) + ).toBeVisible(); // The file landed in the authorized folder. - expect(readdirSync(downloadsDir).length).toBeGreaterThan(0); + const finalPath = join(downloadsDir, 'E2E Movie.mp4'); + expect(readdirSync(downloadsDir)).toEqual(['E2E Movie.mp4']); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); // Completed items expose play / reveal / remove actions. await expect( - item.getByRole('button').filter({ hasText: 'play_arrow' }) + card.getByRole('button', { name: 'Play: E2E Movie' }).last() ).toBeVisible(); await expect( - item.getByRole('button').filter({ hasText: 'folder_open' }) + card.getByRole('button', { + name: 'Show in Folder: E2E Movie', + }) ).toBeVisible(); - - await item - .getByRole('button') - .filter({ hasText: 'delete' }) + await expect( + card.getByText('Offline', { exact: true }) + ).toHaveCount(0); + await expect( + card.getByText('Download Portal', { exact: true }) + ).toHaveCount(0); + await card + .getByRole('button', { + name: 'More actions: E2E Movie', + }) .click(); + const sourceHeader = app.mainWindow.locator( + '.cdk-overlay-pane app-download-source-menu-header' + ); + await expect( + sourceHeader.getByText('Source', { exact: true }) + ).toBeVisible(); + await expect(sourceHeader).toContainText('Download Portal'); + await app.mainWindow.keyboard.press('Escape'); + + // Removing the finalized file must move the persisted completed + // row out of Ready to watch when offline-detail playback refreshes + // the authoritative list, then return to Needs attention. + await card + .getByRole('button', { + name: 'Open details: E2E Movie', + exact: true, + }) + .click(); + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(`/workspace/downloads/${startResult?.id}`) + ); + await expect( + app.mainWindow.getByText('Available offline').first() + ).toBeVisible(); + unlinkSync(finalPath); + await app.mainWindow + .getByRole('button', { + name: 'Play offline', + exact: true, + }) + .click(); + await app.mainWindow.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); + const missingRow = app.mainWindow.getByTestId( + `download-queue-item-${startResult?.id}` + ); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Needs attention', + }) + ).toBeVisible(); + await expect(missingRow).toBeVisible({ timeout: 20000 }); + await expect(missingRow.getByRole('status')).toContainText( + 'File missing' + ); + await expect(card).toHaveCount(0); + await expect( + missingRow.getByRole('button', { name: 'Play: E2E Movie' }) + ).toHaveCount(0); + await expect( + missingRow.getByRole('button', { + name: 'Show in Folder: E2E Movie', + }) + ).toHaveCount(0); + + await missingRow + .getByRole('button', { + name: 'Download E2E Movie again', + }) + .click(); + await expect(card).toBeVisible({ timeout: 20000 }); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); + + await card + .getByRole('button', { + name: 'More actions: E2E Movie', + }) + .click(); + await app.mainWindow + .getByRole('menuitem', { + name: 'Remove from manager: E2E Movie', + }) + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Remove from manager?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('button', { + name: 'Remove from manager', + exact: true, + }) + .click(); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); + } finally { + await closeElectronApp(app); + await fileServer.close(); + } + }); + + test('@downloads @electron keeps global and scoped libraries truthful while filters, search, grouping, and scrolling stay functional', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const fileServer = await createMutableTextServer('library payload', { + contentType: 'video/mp4', + resourcePath: '/media/library-item.mp4', + }); + // Keep one real transfer alive while route scope changes prove that + // the shell badge remains global. + const activeServer = await createThrottledRangeServer(5000); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Library Source A', + username: 'multisrc1', + password: 'multisrc1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await addXtreamPortal(app.mainWindow, { + name: 'Library Source B', + username: 'multisrc2', + password: 'multisrc2', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + const sourceA = await getPlaylistId( + app.mainWindow, + 'Library Source A' + ); + const sourceB = await getPlaylistId( + app.mainWindow, + 'Library Source B' + ); + const { xtreamId: seriesXtreamId } = await getSeriesTarget( + app.mainWindow, + sourceA + ); + const { categoryId: vodCategoryId, xtreamId: vodXtreamId } = + await getVodTarget(app.mainWindow, sourceA); + await navigateWithinWorkspace( + app.mainWindow, + `/workspace/xtreams/${sourceB}/downloads` + ); + + const downloadsDir = join(dataDir, 'e2e-library-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.getByTestId('downloads-folder') + ).toContainText('e2e-library-downloads'); + + const common = { + downloadFolder: downloadsDir, + url: fileServer.resourceUrl, + }; + const alphaMovieId = await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: vodXtreamId, + contentType: 'vod', + title: 'Alpha Movie', + }); + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8201, + contentType: 'episode', + seriesXtreamId, + seasonNumber: 1, + episodeNumber: 1, + title: 'Aurora - S01E01 - Arrival', + }); + const secondSeriesEpisodeId = await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8202, + contentType: 'episode', + seriesXtreamId, + seasonNumber: 1, + episodeNumber: 2, + title: 'Aurora - S01E02 - Signal', + }); + for (let index = 0; index < 6; index += 1) { + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8300 + index, + contentType: 'vod', + title: `Alpha Archive ${index + 1}`, + }); + } + const betaMovieId = await startDownload(app.mainWindow, { + ...common, + playlistId: sourceB, + xtreamId: 9101, + contentType: 'vod', + title: 'Beta Movie', + }); + for (let index = 0; index < 4; index += 1) { + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceB, + xtreamId: 9300 + index, + contentType: 'vod', + title: `Beta Archive ${index + 1}`, + }); + } + + const cards = app.mainWindow.locator( + 'article[data-test-id^="download-library-"]' + ); + await expect(cards).toHaveCount(5, { timeout: 30000 }); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${alphaMovieId}` + ) + ).toHaveCount(0); + + const activeMovieId = await startDownload(app.mainWindow, { + downloadFolder: downloadsDir, + playlistId: sourceA, + xtreamId: 8401, + contentType: 'vod', + title: 'Alpha Active Movie', + url: activeServer.url, + }); + const activeItem = app.mainWindow.getByTestId( + `download-queue-item-${activeMovieId}` + ); + const globalBadge = app.mainWindow.getByTestId( + 'global-download-count' + ); + await expect(globalBadge).toHaveText('1', { timeout: 20000 }); + await expect(activeItem).toHaveCount(0); + + await navigateWithinWorkspace( + app.mainWindow, + `/workspace/xtreams/${sourceA}/downloads` + ); + await expect(globalBadge).toHaveText('1'); + await expect(cards).toHaveCount(8); + await expect(activeItem).toBeVisible(); + await expect( + activeItem.locator('.download-queue__status') + ).toContainText('Downloading', { timeout: 20000 }); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${alphaMovieId}` + ) + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toHaveCount(0); + + const alphaMovieCard = app.mainWindow.getByTestId( + `download-library-movie-${alphaMovieId}` + ); + await installDownloadPlayCapture(app); + await alphaMovieCard + .getByRole('button', { + name: 'Open details: Alpha Movie', + exact: true, + }) + .click(); + const expectedOfflinePath = `/workspace/xtreams/${sourceA}/downloads/${alphaMovieId}`; + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(expectedOfflinePath) + ); + await expect( + app.mainWindow.getByText('Available offline').first() + ).toBeVisible(); + const playOffline = app.mainWindow.getByRole('button', { + name: 'Play offline', + exact: true, + }); + await expect(playOffline).toBeVisible(); + await expect( + app.mainWindow.getByRole('button', { + name: /Play from (?:this )?source/i, + }) + ).toHaveCount(0); + await expect( + app.mainWindow.locator('app-workspace-shell-context-sidebar') + ).toHaveCount(0); + + await playOffline.click(); + await expect + .poll(async () => (await getDownloadPlayPaths(app)).length) + .toBe(1); + expect(await getDownloadPlayPaths(app)).toEqual([ + join(downloadsDir, 'Alpha Movie.mp4'), + ]); + + const viewInPortal = app.mainWindow.getByRole('button', { + name: 'View in portal', + exact: true, + }); + await expect(viewInPortal).toBeEnabled({ timeout: 20000 }); + await viewInPortal.click(); + const expectedVodPath = `/workspace/xtreams/${sourceA}/vod/${vodCategoryId}/${vodXtreamId}`; + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(expectedVodPath) + ); + await expect( + app.mainWindow.locator('app-workspace-shell-context-sidebar') + ).toBeVisible(); + const providerDetail = app.mainWindow.locator( + 'main app-portal-detail-shell' + ); + await expect( + providerDetail.getByRole('button', { + name: 'Play', + exact: true, + }) + ).toBeVisible(); + await expect( + providerDetail.getByText('Offline', { exact: true }) + ).toHaveCount(0); + await expect( + providerDetail.getByRole('button', { + name: 'Play Local', + exact: true, + }) + ).toHaveCount(0); + await expect( + providerDetail.getByRole('button', { + name: 'Download', + exact: true, + }) + ).toHaveCount(0); + await expect( + providerDetail.getByRole('button', { + name: /Play from (?:this )?source/i, + }) + ).toHaveCount(0); + + await openDownloadsPage(app.mainWindow); + await expect(globalBadge).toHaveText('1'); + await expect(cards).toHaveCount(13); + + await selectDownloadCoverSize(app.mainWindow, 'small'); + await expect(cards).toHaveCount(13); + const smallCover = await measureLibraryCard(app.mainWindow); + await selectDownloadCoverSize(app.mainWindow, 'medium'); + await expect(cards).toHaveCount(13); + const mediumCover = await measureLibraryCard(app.mainWindow); + await selectDownloadCoverSize(app.mainWindow, 'large'); + await expect(cards).toHaveCount(13); + const largeCover = await measureLibraryCard(app.mainWindow); + + expect(smallCover.gap).toBe(12); + expect(mediumCover.gap).toBe(16); + expect(largeCover.gap).toBe(20); + expect(mediumCover.width).toBeGreaterThan(smallCover.width); + expect(largeCover.width).toBeGreaterThan(mediumCover.width); + + const seriesCard = app.mainWindow.getByTestId( + `download-library-series-${sourceA}-${seriesXtreamId}` + ); + await expect(seriesCard).toContainText('2 episodes'); + await expect( + seriesCard.getByText('Library Source A', { exact: true }) + ).toHaveCount(0); + await seriesCard + .getByRole('button', { + name: 'More actions: Aurora', + }) + .click(); + await expect( + app.mainWindow.locator( + '.cdk-overlay-pane app-download-source-menu-header' + ) + ).toContainText('Library Source A'); + await app.mainWindow.keyboard.press('Escape'); + + await app.mainWindow + .getByTestId('downloads-filter-in-progress') + .click(); + await expect(cards).toHaveCount(0); + await expect(activeItem).toBeVisible(); + await expect( + app.mainWindow.getByTestId('downloads-filter-in-progress') + ).toContainText('1'); + await app.mainWindow.getByTestId('downloads-filter-all').click(); + await expect(cards).toHaveCount(13); + + await seriesCard + .getByRole('button', { + name: 'Open details: Aurora', + exact: true, + }) + .click(); + const expectedSeriesPath = `/workspace/downloads/${secondSeriesEpisodeId}`; + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(expectedSeriesPath) + ); + await expect( + app.mainWindow.getByText('Available offline').first() + ).toBeVisible(); + await expect( + app.mainWindow.locator('[data-testid^="offline-episode-"]') + ).toHaveCount(2); + await expect( + app.mainWindow.getByRole('button', { + name: /Play offline: S01E01/, + }) + ).toBeVisible(); + await expect( + app.mainWindow.getByRole('button', { + name: /Play offline: S01E02/, + }) + ).toBeVisible(); + await expect( + app.mainWindow.locator('app-workspace-shell-context-sidebar') + ).toHaveCount(0); + + await openDownloadsPage(app.mainWindow); + await expect(seriesCard).toBeVisible(); + + await activeItem + .getByRole('button', { name: 'Pause Alpha Active Movie' }) + .click(); + await expect( + activeItem.locator('.download-queue__status') + ).toContainText('Paused', { timeout: 20000 }); + await expect(globalBadge).toHaveCount(0); + + await app.mainWindow.getByTestId('downloads-filter-series').click(); + await expect(cards).toHaveCount(1); + await expect(seriesCard).toBeVisible(); + await app.mainWindow.getByTestId('downloads-filter-movie').click(); + await expect(cards).toHaveCount(12); + await expect(seriesCard).toHaveCount(0); + await app.mainWindow.getByTestId('downloads-filter-all').click(); + + const search = app.mainWindow.getByRole('searchbox', { + name: 'Search', + }); + await search.fill('Beta Movie'); + await expect + .poll( + () => new URL(app.mainWindow.url()).searchParams.get('q'), + { timeout: 10000 } + ) + .toBe('Beta Movie'); + await expect(cards).toHaveCount(1); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toBeVisible(); + await search.fill(''); + await expect + .poll( + () => new URL(app.mainWindow.url()).searchParams.get('q'), + { timeout: 10000 } + ) + .toBeNull(); + await expect(cards).toHaveCount(13); + + const header = app.mainWindow.getByTestId('downloads-header'); + const filters = app.mainWindow.getByTestId('downloads-filters'); + const content = app.mainWindow.getByTestId('downloads-content'); + const headerBefore = await header.boundingBox(); + const filtersBefore = await filters.boundingBox(); + const scroll = await content.evaluate((element) => { + const outer = + element.closest('.workspace-content'); + element.scrollTop = element.scrollHeight; + return { + clientHeight: element.clientHeight, + outerScrollTop: outer?.scrollTop ?? -1, + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + }); + const headerAfter = await header.boundingBox(); + const filtersAfter = await filters.boundingBox(); + + expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight); + expect(scroll.scrollTop).toBeGreaterThan(0); + expect(scroll.outerScrollTop).toBe(0); + expect(headerAfter?.y).toBe(headerBefore?.y); + expect(filtersAfter?.y).toBe(filtersBefore?.y); + } finally { + await closeElectronApp(app); + await activeServer.close(); + await fileServer.close(); + } + }); + + test('@downloads @stalker @electron shows only downloaded non-contiguous series episodes offline', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const seriesFixture = await fetchStalkerCategoryFixture( + request, + 'series' + ); + const { id: seriesId, title: providerTitle } = + getStalkerSeriesDownloadTarget(seriesFixture.items[0]); + + const fileServer = await createMutableTextServer( + 'stalker offline episode', + { + contentType: 'video/mp4', + resourcePath: '/media/stalker-series-episode.mp4', + } + ); + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow, { + name: 'Stalker Download Source', + }); + await waitForStalkerCatalog(app.mainWindow); + const playlistId = await getPlaylistId( + app.mainWindow, + 'Stalker Download Source' + ); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-stalker-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + + const common = { + contentType: 'episode' as const, + downloadFolder: downloadsDir, + playlistId, + seasonNumber: 1, + seriesXtreamId: seriesId, + url: fileServer.resourceUrl, + }; + const firstEpisodeId = await startDownload(app.mainWindow, { + ...common, + episodeNumber: 1, + title: `${providerTitle} - S01E01 - First`, + xtreamId: seriesId * 100 + 1, + }); + const thirdEpisodeId = await startDownload(app.mainWindow, { + ...common, + episodeNumber: 3, + title: `${providerTitle} - S01E03 - Third`, + xtreamId: seriesId * 100 + 3, + }); + + const seriesCard = app.mainWindow.getByTestId( + `download-library-series-${playlistId}-${seriesId}` + ); + await expect(seriesCard).toBeVisible({ timeout: 30000 }); + await expect(seriesCard).toContainText('2 episodes'); + await seriesCard + .getByRole('button', { + name: `Open details: ${providerTitle}`, + exact: true, + }) + .click(); + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(`/workspace/downloads/${thirdEpisodeId}`) + ); + + const offlineEpisodes = app.mainWindow.locator( + '[data-testid^="offline-episode-"]' + ); + await expect(offlineEpisodes).toHaveCount(2); + await expect( + app.mainWindow.locator( + `[data-testid="offline-episode-${firstEpisodeId}"]` + ) + ).toContainText('S01E01'); + await expect( + app.mainWindow.locator( + `[data-testid="offline-episode-${thirdEpisodeId}"]` + ) + ).toContainText('S01E03'); + await expect( + app.mainWindow.getByText('S01E02', { exact: true }) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + await fileServer.close(); + } + }); + + test('@downloads @electron deletes retained failed partials when removing one item or clearing finished downloads', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const truncatedServer = await createTruncatedDownloadServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Partial Cleanup Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-partial-cleanup'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.getByTestId('downloads-folder') + ).toContainText('e2e-partial-cleanup'); + + const playlistId = await getPlaylistId( + app.mainWindow, + 'Partial Cleanup Portal' + ); + const removeId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9501, + contentType: 'vod', + title: 'Remove Retained Partial', + url: truncatedServer.url, + downloadFolder: downloadsDir, + }); + const clearId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9502, + contentType: 'vod', + title: 'Clear Retained Partial', + url: truncatedServer.url, + downloadFolder: downloadsDir, + }); + + for (const id of [removeId, clearId]) { + await expect + .poll( + () => + app.mainWindow.evaluate( + async (downloadId) => + ( + await window.electron?.downloadsGet?.( + downloadId + ) + )?.status ?? null, + id + ), + { timeout: 30000 } + ) + .toBe('failed'); + } + + const failedRows = await app.mainWindow.evaluate( + async (ids) => + Promise.all( + ids.map((id) => window.electron?.downloadsGet?.(id)) + ), + [removeId, clearId] + ); + const partialPaths = failedRows.map((row) => { + expect(row?.filePath).toEqual(expect.any(String)); + expect(row?.bytesDownloaded).toBe( + truncatedServer.payload.length + ); + expect(row?.totalBytes).toBe(truncatedServer.totalBytes); + return `${row?.filePath}.part`; + }); + for (const partialPath of partialPaths) { + expect(existsSync(partialPath)).toBe(true); + expect(statSync(partialPath).size).toBe( + truncatedServer.payload.length + ); + } + + const removeItem = app.mainWindow.getByTestId( + `download-queue-item-${removeId}` + ); + await expect(removeItem).toBeVisible(); + await removeItem + .getByRole('button', { + name: 'Remove Remove Retained Partial from manager', + }) + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Remove partial download?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('button', { + name: 'Remove from manager', + exact: true, + }) + .click(); + await expect(removeItem).toHaveCount(0); + await expect + .poll(() => + app.mainWindow.evaluate( + async (id) => + (await window.electron?.downloadsGet?.(id)) ?? null, + removeId + ) + ) + .toBeNull(); + expect(existsSync(partialPaths[0])).toBe(false); + expect(existsSync(partialPaths[1])).toBe(true); + + await app.mainWindow + .getByTestId('downloads-clear-finished') + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Clear finished downloads?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('dialog', { + name: 'Clear finished downloads?', + }) + .getByRole('button', { + name: 'Clear finished', + exact: true, + }) + .click(); + await expect( + app.mainWindow.getByTestId(`download-queue-item-${clearId}`) + ).toHaveCount(0); + await expect + .poll(() => + app.mainWindow.evaluate( + async (id) => + (await window.electron?.downloadsGet?.(id)) ?? null, + clearId + ) + ) + .toBeNull(); + expect(existsSync(partialPaths[1])).toBe(false); await expect( app.mainWindow.getByText('No downloads yet') ).toBeVisible(); } finally { await closeElectronApp(app); - await fileServer.close(); + await truncatedServer.close(); } }); @@ -280,26 +1116,37 @@ test.describe('Electron Downloads', () => { .getByRole('button', { name: 'Change Folder' }) .click(); await expect( - app.mainWindow.locator('.downloads__folder-inline-path') + app.mainWindow.getByTestId('downloads-folder') ).toContainText('e2e-pause-downloads'); + const playlistId = await getPlaylistId( + app.mainWindow, + 'Pause Portal' + ); const startResult = await app.mainWindow.evaluate( - async ({ url, folder }) => + async ({ url, folder, sourceId }) => window.electron?.downloadsStart?.({ - playlistId: 'e2e-playlist', + playlistId: sourceId, xtreamId: 7373, contentType: 'vod', title: 'E2E Pause Movie', url, downloadFolder: folder, }), - { url: rangeServer.url, folder: downloadsDir } + { + url: rangeServer.url, + folder: downloadsDir, + sourceId: playlistId, + } ); expect(startResult?.error ?? null).toBeNull(); + expect(startResult?.id).toEqual(expect.any(Number)); - const item = app.mainWindow.locator('.downloads__item'); - await expect(item).toHaveCount(1, { timeout: 20000 }); - await expect(item.locator('.downloads__item-status')).toContainText( + const item = app.mainWindow.getByTestId( + `download-queue-item-${startResult?.id}` + ); + await expect(item).toBeVisible({ timeout: 20000 }); + await expect(item.locator('.download-queue__status')).toContainText( 'Downloading', { timeout: 20000 } ); @@ -307,10 +1154,9 @@ test.describe('Electron Downloads', () => { // Pause mid-transfer: the row flips to Paused and only a .part // file exists on disk (final file absent, progress retained). await item - .getByRole('button') - .filter({ hasText: 'pause' }) + .getByRole('button', { name: 'Pause E2E Pause Movie' }) .click(); - await expect(item.locator('.downloads__item-status')).toContainText( + await expect(item.locator('.download-queue__status')).toContainText( 'Paused', { timeout: 20000 } ); @@ -325,13 +1171,18 @@ test.describe('Electron Downloads', () => { // Resume: the runtime must continue via Range/If-Range instead of // restarting, and the assembled file must match the payload. await item - .getByRole('button') - .filter({ hasText: 'play_arrow' }) + .getByRole('button', { name: 'Resume E2E Pause Movie' }) .click(); - await expect(item.locator('.downloads__item-status')).toContainText( - 'Completed', - { timeout: 30000 } - ); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${startResult?.id}` + ) + ).toBeVisible({ timeout: 30000 }); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Ready to watch', + }) + ).toBeVisible(); const resumeRequest = rangeServer.requests.find( (entry) => entry.range diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index b624e65fa..7397dbe61 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -13,7 +13,10 @@ import { ANNOUNCE_PLAYLIST_OPEN_LISTENER, OPEN_FILE, } from '@iptvnator/shared/interfaces'; -import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import type { + DownloadMetadataSnapshot, + ElectronBridgeApi, +} from '@iptvnator/shared/interfaces'; import { dbPreloadCases, epgPreloadCases, @@ -278,6 +281,24 @@ describe('main preload DB IPC contract', () => { ); }); + it('forwards download metadata updates with the download id', async () => { + const api = getExposedApi(); + const metadataSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Offline title', + }; + + await api.downloadsUpdateMetadata(42, metadataSnapshot); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'DOWNLOADS_UPDATE_METADATA', + 42, + metadataSnapshot + ); + }); + it('keeps the legacy save-content progress bridge scoped to progress events', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 28927ef91..e62eaaf3b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -24,6 +24,7 @@ import { toXtreamPreloadPerformanceTargetMethod, } from './xtream-preload-performance-capture'; import type { + DownloadMetadataSnapshot, EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, @@ -978,12 +979,23 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder), downloadsRetry: (downloadId: number, downloadFolder: string) => ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder), + downloadsRedownloadMissing: (downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_REDOWNLOAD_MISSING', downloadId), downloadsRemove: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId), downloadsGetList: (playlistId?: string) => ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId), downloadsGet: (downloadId: number) => ipcRenderer.invoke('DOWNLOADS_GET', downloadId), + downloadsUpdateMetadata: ( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot + ) => + ipcRenderer.invoke( + 'DOWNLOADS_UPDATE_METADATA', + downloadId, + metadataSnapshot + ), downloadsGetDefaultFolder: () => ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'), downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'), diff --git a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts index 695a273ec..99df1ee5e 100644 --- a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts @@ -22,7 +22,6 @@ function createDeleteHarness() { ['select:favorites', [{ id: 1 }, { id: 2 }]], ['select:recently-viewed', [{ id: 3 }]], ['select:playback-positions', []], - ['select:downloads', [{ id: 4 }]], ['select:categories', [{ id: 10 }, { id: 11 }]], [ 'select:content', @@ -80,15 +79,14 @@ describe('playlist delete performance phases', () => { ) ).resolves.toEqual({ success: true }); - expect(harness.transaction).toHaveBeenCalledTimes(7); - // Seven committed chunks plus the final playlist row each keep both + expect(harness.transaction).toHaveBeenCalledTimes(6); + // Six committed chunks plus the final playlist row each keep both // the pre-write and post-progress cooperative checkpoints. - expect(checkpoint).toHaveBeenCalledTimes(16); - expect(harness.timeline.slice(1, 7)).toEqual([ + expect(checkpoint).toHaveBeenCalledTimes(14); + expect(harness.timeline.slice(1, 6)).toEqual([ 'select:favorites', 'select:recently-viewed', 'select:playback-positions', - 'select:downloads', 'select:categories', 'select:content', ]); @@ -117,7 +115,7 @@ describe('playlist delete performance phases', () => { }, { boundary: 'end', - metadata: { itemCount: 211 }, + metadata: { itemCount: 210 }, phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, }, { @@ -126,7 +124,7 @@ describe('playlist delete performance phases', () => { }, { boundary: 'end', - metadata: { itemCount: 212 }, + metadata: { itemCount: 211 }, phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, }, ]); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 8401fa991..a8e93b2e9 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -591,7 +591,6 @@ export async function updatePlaylist( interface PlaylistDeletionCollection { readonly categoryIds: number[]; readonly contentRows: Array<{ id: number }>; - readonly downloadRows: Array<{ id: number }>; readonly favoriteRows: Array<{ id: number }>; readonly playbackPositionRows: Array<{ id: number }>; readonly recentlyViewedRows: Array<{ id: number }>; @@ -601,29 +600,21 @@ async function collectPlaylistDeletionRows( db: AppDatabase, playlistId: string ): Promise { - const [ - favoriteRows, - recentlyViewedRows, - playbackPositionRows, - downloadRows, - ] = await Promise.all([ - db - .select({ id: schema.favorites.id }) - .from(schema.favorites) - .where(eq(schema.favorites.playlistId, playlistId)), - db - .select({ id: schema.recentlyViewed.id }) - .from(schema.recentlyViewed) - .where(eq(schema.recentlyViewed.playlistId, playlistId)), - db - .select({ id: schema.playbackPositions.id }) - .from(schema.playbackPositions) - .where(eq(schema.playbackPositions.playlistId, playlistId)), - db - .select({ id: schema.downloads.id }) - .from(schema.downloads) - .where(eq(schema.downloads.playlistId, playlistId)), - ]); + const [favoriteRows, recentlyViewedRows, playbackPositionRows] = + await Promise.all([ + db + .select({ id: schema.favorites.id }) + .from(schema.favorites) + .where(eq(schema.favorites.playlistId, playlistId)), + db + .select({ id: schema.recentlyViewed.id }) + .from(schema.recentlyViewed) + .where(eq(schema.recentlyViewed.playlistId, playlistId)), + db + .select({ id: schema.playbackPositions.id }) + .from(schema.playbackPositions) + .where(eq(schema.playbackPositions.playlistId, playlistId)), + ]); const categoryRows = await db .select({ id: schema.categories.id }) @@ -641,7 +632,6 @@ async function collectPlaylistDeletionRows( return { categoryIds, contentRows, - downloadRows, favoriteRows, playbackPositionRows, recentlyViewedRows, @@ -655,7 +645,6 @@ function countPlaylistDeletionRows( collection.favoriteRows.length + collection.recentlyViewedRows.length + collection.playbackPositionRows.length + - collection.downloadRows.length + collection.contentRows.length + collection.categoryIds.length ); @@ -686,12 +675,6 @@ async function deleteCollectedPlaylistRows( schema.playbackPositions.id, schema.playbackPositions, ], - [ - 'deleting-downloads', - collection.downloadRows.map((row) => row.id), - schema.downloads.id, - schema.downloads, - ], [ 'deleting-content', collection.contentRows.map((row) => row.id), diff --git a/apps/electron-backend/src/app/events/database/download-artwork-url.ts b/apps/electron-backend/src/app/events/database/download-artwork-url.ts new file mode 100644 index 000000000..a61303b34 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-artwork-url.ts @@ -0,0 +1,191 @@ +const IMAGE_PATH_EXTENSION = /\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i; + +const TRUSTED_IMAGE_HOSTS = new Set(['image.tmdb.org', 'picsum.photos']); + +const IMAGE_PATH_HINTS = new Set([ + 'backdrop', + 'backdrops', + 'cover', + 'covers', + 'getimage', + 'image', + 'images', + 'logo', + 'logos', + 'poster', + 'posters', + 'still', + 'stills', +]); + +const CREDENTIAL_QUERY_TERMS = [ + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'secret', + 'session', + 'signature', + 'token', + 'username', +]; + +const CREDENTIAL_PATH_KEYS = new Set([ + 'accesskey', + 'accesstoken', + 'apikey', + 'auth', + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'credentials', + 'devicemac', + 'key', + 'mac', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'privatekey', + 'refreshtoken', + 'secret', + 'secretkey', + 'session', + 'sig', + 'signature', + 'signingkey', + 'token', + 'username', +]); + +function invalidArtworkUrl(): never { + throw new Error('Invalid download metadata snapshot'); +} + +function isCredentialQueryKey(key: string): boolean { + return isCredentialQueryToken(normalizeToken(key)); +} + +function isCredentialQueryToken(normalized: string): boolean { + if ( + normalized === 'auth' || + normalized === 'key' || + normalized === 'mac' || + normalized === 'sig' || + normalized.endsWith('auth') || + normalized.endsWith('mac') + ) { + return true; + } + if (CREDENTIAL_QUERY_TERMS.some((term) => normalized.includes(term))) { + return true; + } + return [ + 'accesskey', + 'apikey', + 'privatekey', + 'secretkey', + 'signingkey', + ].some((term) => normalized.includes(term)); +} + +function isCredentialPathSegment(segment: string): boolean { + const keyValueSeparator = segment.search(/[=:]/); + const key = + keyValueSeparator === -1 + ? segment + : segment.slice(0, keyValueSeparator); + return CREDENTIAL_PATH_KEYS.has(normalizeToken(key)); +} + +function normalizeToken(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function getDecodedPathname(url: URL): string { + try { + return decodeURIComponent(url.pathname).replace(/\/+$/, ''); + } catch { + return invalidArtworkUrl(); + } +} + +function hasPositiveImageSignal(url: URL, decodedPathname: string): boolean { + if ( + IMAGE_PATH_EXTENSION.test(decodedPathname) || + TRUSTED_IMAGE_HOSTS.has(url.hostname) + ) { + return true; + } + const hasImagePathHint = decodedPathname + .split('/') + .filter(Boolean) + .some((segment) => IMAGE_PATH_HINTS.has(normalizeToken(segment))); + if (hasImagePathHint) { + return true; + } + return [...url.searchParams.entries()].some( + ([key, value]) => + ['action', 'method'].includes(normalizeToken(key)) && + ['getimage', 'image'].includes(normalizeToken(value)) + ); +} + +export function getNormalizedDownloadUrlIdentity( + value: string +): string | undefined { + try { + const url = new URL(value.trim()); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + url.hash = ''; + return url.href; + } catch { + return undefined; + } +} + +export function normalizeDownloadArtworkUrl( + value: unknown +): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + return invalidArtworkUrl(); + } + + const normalized = value.trim(); + if (normalized === '') { + return undefined; + } + let url: URL; + try { + url = new URL(normalized); + } catch { + return invalidArtworkUrl(); + } + const decodedPathname = getDecodedPathname(url); + const hasCredentialPathToken = decodedPathname + .split('/') + .filter(Boolean) + .some(isCredentialPathSegment); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username !== '' || + url.password !== '' || + url.hash !== '' || + [...url.searchParams.keys()].some(isCredentialQueryKey) || + hasCredentialPathToken || + !hasPositiveImageSignal(url, decodedPathname) + ) { + return invalidArtworkUrl(); + } + return normalized; +} diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts new file mode 100644 index 000000000..d23b32f22 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts @@ -0,0 +1,105 @@ +import { + decorateDownloadItem, + getDownloadFileAvailability, + isAvailableDownloadFile, + type DownloadLstat, +} from './download-file-availability'; + +function lstatResult(options: { + isFile: boolean; + isSymbolicLink?: boolean; +}): DownloadLstat { + return () => ({ + isFile: () => options.isFile, + isSymbolicLink: () => options.isSymbolicLink ?? false, + }); +} + +describe('download file availability', () => { + it('marks only a completed regular non-symbolic-link file available', () => { + const lstat = lstatResult({ isFile: true }); + + expect( + getDownloadFileAvailability( + { + filePath: '/downloads/movie.mp4', + status: 'completed', + }, + lstat + ) + ).toBe('available'); + expect(isAvailableDownloadFile('/downloads/movie.mp4', lstat)).toBe( + true + ); + }); + + it.each([ + ['missing path', undefined, lstatResult({ isFile: true })], + ['empty path', '', lstatResult({ isFile: true })], + [ + 'filesystem error', + '/downloads/missing.mp4', + (() => { + throw new Error('ENOENT'); + }) as DownloadLstat, + ], + ['directory', '/downloads/folder', lstatResult({ isFile: false })], + [ + 'symbolic link', + '/downloads/link.mp4', + lstatResult({ isFile: true, isSymbolicLink: true }), + ], + ])('marks a completed %s missing', (_label, filePath, lstat) => { + expect( + getDownloadFileAvailability( + { filePath, status: 'completed' }, + lstat + ) + ).toBe('missing'); + expect(isAvailableDownloadFile(filePath, lstat)).toBe(false); + }); + + it('does not inspect unfinished downloads', () => { + const lstat = jest.fn, [string]>(); + + expect( + getDownloadFileAvailability( + { + filePath: '/downloads/queued.mp4', + status: 'queued', + }, + lstat + ) + ).toBe('not-applicable'); + expect(lstat).not.toHaveBeenCalled(); + }); + + it('decorates a row without mutating it', () => { + const row = Object.freeze({ + filePath: '/downloads/movie.mp4', + id: 42, + metadataSnapshot: JSON.stringify({ + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Movie', + }), + status: 'completed' as const, + title: 'Movie', + }); + + expect( + decorateDownloadItem(row, lstatResult({ isFile: true })) + ).toEqual({ + ...row, + metadataSnapshot: { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Movie', + }, + fileAvailability: 'available', + }); + expect(row).not.toHaveProperty('fileAvailability'); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts new file mode 100644 index 000000000..a269c11b0 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -0,0 +1,169 @@ +import { + type DownloadMetadataSnapshot, + type ElectronBridgeDownloadStatus, + type ElectronDownloadFileAvailability, +} from '@iptvnator/shared/interfaces'; +import { lstatSync, type Stats } from 'node:fs'; +import { lstat } from 'node:fs/promises'; +import { decodeDownloadMetadataSnapshot } from './download-metadata-snapshot'; + +interface DownloadFileRow { + filePath?: string | null; + metadataSnapshot?: string | null; + status: ElectronBridgeDownloadStatus; +} + +export type DownloadLstat = ( + filePath: string +) => Pick; + +type DownloadAsyncLstat = ( + filePath: string +) => Promise>; + +type DownloadFileAvailabilityProbe = ( + filePath: string +) => Promise; + +const DEFAULT_MAX_CONCURRENT_FILE_PROBES = 4; + +function createDownloadFileAvailabilityProbe( + asyncLstat: DownloadAsyncLstat = lstat, + maxConcurrent = DEFAULT_MAX_CONCURRENT_FILE_PROBES +): DownloadFileAvailabilityProbe { + const concurrency = Math.max(1, Math.floor(maxConcurrent)); + const pending: Array<() => void> = []; + // Coalesce only active probes. Completed results are discarded so an + // externally removed file is visible on the next list refresh. + const inFlight = new Map>(); + let active = 0; + + const acquire = (): Promise => { + if (active < concurrency) { + active += 1; + return Promise.resolve(); + } + return new Promise((resolve) => pending.push(resolve)); + }; + + const release = (): void => { + const next = pending.shift(); + if (next) { + next(); + } else { + active -= 1; + } + }; + + const inspect = async (filePath: string): Promise => { + await acquire(); + try { + const stats = await asyncLstat(filePath); + return stats.isFile() && !stats.isSymbolicLink(); + } catch { + return false; + } finally { + release(); + } + }; + + return (filePath: string) => { + const existing = inFlight.get(filePath); + if (existing) { + return existing; + } + + const probe = inspect(filePath); + inFlight.set(filePath, probe); + const clear = () => { + if (inFlight.get(filePath) === probe) { + inFlight.delete(filePath); + } + }; + void probe.then(clear, clear); + return probe; + }; +} + +const probeDownloadFileAvailability = createDownloadFileAvailabilityProbe(); + +export function isAvailableDownloadFile( + filePath: string | null | undefined, + lstat: DownloadLstat = lstatSync +): boolean { + if (!filePath) { + return false; + } + + try { + const stats = lstat(filePath); + return stats.isFile() && !stats.isSymbolicLink(); + } catch { + return false; + } +} + +export function getDownloadFileAvailability( + download: DownloadFileRow, + lstat: DownloadLstat = lstatSync +): ElectronDownloadFileAvailability { + if (download.status !== 'completed') { + return 'not-applicable'; + } + + return isAvailableDownloadFile(download.filePath, lstat) + ? 'available' + : 'missing'; +} + +export async function getDownloadFileAvailabilityAsync( + download: DownloadFileRow, + probe: DownloadFileAvailabilityProbe = probeDownloadFileAvailability +): Promise { + if (download.status !== 'completed') { + return 'not-applicable'; + } + + if (!download.filePath) { + return 'missing'; + } + + return (await probe(download.filePath)) ? 'available' : 'missing'; +} + +export function decorateDownloadItem( + download: T, + lstat: DownloadLstat = lstatSync +): Omit & { + metadataSnapshot: DownloadMetadataSnapshot | undefined; + fileAvailability: ElectronDownloadFileAvailability; +} { + return { + ...download, + metadataSnapshot: decodeDownloadMetadataSnapshot( + download.metadataSnapshot + ), + fileAvailability: getDownloadFileAvailability(download, lstat), + }; +} + +export async function decorateDownloadItemAsync( + download: T, + probe: DownloadFileAvailabilityProbe = probeDownloadFileAvailability +): Promise< + Omit & { + metadataSnapshot: DownloadMetadataSnapshot | undefined; + fileAvailability: ElectronDownloadFileAvailability; + } +> { + return { + ...download, + metadataSnapshot: decodeDownloadMetadataSnapshot( + download.metadataSnapshot + ), + fileAvailability: await getDownloadFileAvailabilityAsync( + download, + probe + ), + }; +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata-input-safety.ts b/apps/electron-backend/src/app/events/database/download-metadata-input-safety.ts new file mode 100644 index 000000000..f1a52a4ad --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-input-safety.ts @@ -0,0 +1,113 @@ +const RAW_MAX_ARRAY_LENGTH = 256; +const RAW_MAX_DEPTH = 6; +const RAW_MAX_KEYS = 2_048; +const RAW_MAX_NODES = 1_024; + +const FORBIDDEN_KEYS = new Set([ + 'accesstoken', + 'apikey', + 'authorization', + 'cookie', + 'cookies', + 'credential', + 'credentials', + 'deviceid', + 'headers', + 'macaddress', + 'origin', + 'password', + 'playbackurl', + 'portalauth', + 'portalurl', + 'proxyauthorization', + 'referer', + 'refreshtoken', + 'requestheaders', + 'secret', + 'serialnumber', + 'serverurl', + 'streamurl', + 'token', + 'url', + 'useragent', + 'username', +]); + +function invalidSnapshot(): never { + throw new Error('Invalid download metadata snapshot'); +} + +function oversizedSnapshot(): never { + throw new Error('Download metadata snapshot is too large'); +} + +export function assertSafeDownloadMetadataInput( + input: unknown, + maxRawStringBytes: number +): void { + const pending: Array<{ depth: number; value: unknown }> = [ + { depth: 0, value: input }, + ]; + const visited = new WeakSet(); + let rawStringBytes = 0; + let totalKeys = 0; + let totalNodes = 0; + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) { + continue; + } + const { depth, value } = current; + totalNodes += 1; + if (depth > RAW_MAX_DEPTH || totalNodes > RAW_MAX_NODES) { + invalidSnapshot(); + } + if (typeof value === 'string') { + rawStringBytes += Buffer.byteLength(value, 'utf8'); + if (rawStringBytes > maxRawStringBytes) { + oversizedSnapshot(); + } + continue; + } + if (!value || typeof value !== 'object') { + continue; + } + if (visited.has(value)) { + continue; + } + visited.add(value); + + if (Array.isArray(value)) { + if (value.length > RAW_MAX_ARRAY_LENGTH) { + invalidSnapshot(); + } + for (let index = 0; index < value.length; index += 1) { + pending.push({ depth: depth + 1, value: value[index] }); + } + continue; + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue; + } + totalKeys += 1; + rawStringBytes += Buffer.byteLength(key, 'utf8'); + if (totalKeys > RAW_MAX_KEYS) { + invalidSnapshot(); + } + if (rawStringBytes > maxRawStringBytes) { + oversizedSnapshot(); + } + const normalizedKey = key.toLowerCase().replace(/[-_]/g, ''); + if (FORBIDDEN_KEYS.has(normalizedKey)) { + invalidSnapshot(); + } + pending.push({ + depth: depth + 1, + value: (value as Record)[key], + }); + } + } +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts new file mode 100644 index 000000000..d3ea42be6 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts @@ -0,0 +1,437 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { + decodeDownloadMetadataSnapshot, + DOWNLOAD_METADATA_MAX_BYTES, + encodeDownloadMetadataSnapshot, +} from './download-metadata-snapshot'; +import { normalizeDownloadArtworkUrl } from './download-artwork-url'; + +const validSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'The Example', + plot: 'A bounded offline summary.', + genres: ['Drama', 'Mystery'], + rating: 8.2, + cast: [ + { + tmdbPersonId: 7, + name: 'Example Actor', + role: 'Lead', + profileUrl: 'https://image.tmdb.org/example.jpg', + }, + ], +}; + +describe('download metadata snapshot', () => { + it('encodes a valid normalized snapshot without changing its shape', () => { + expect(encodeDownloadMetadataSnapshot(validSnapshot)).toBe( + JSON.stringify(validSnapshot) + ); + }); + + it('rejects an unsupported snapshot version', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + version: 2, + } as never) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects an unsupported media kind', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + mediaKind: 'live', + } as never) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects an empty display title', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + title: ' ', + }) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects an empty snapshot language', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + language: ' ', + }) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('bounds cast and creator arrays to 30 people', () => { + const people = Array.from({ length: 35 }, (_, index) => ({ + name: `Person ${index}`, + })); + + const decoded = JSON.parse( + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + cast: people, + creators: people, + }) + ) as DownloadMetadataSnapshot; + + expect(decoded.cast).toHaveLength(30); + expect(decoded.creators).toHaveLength(30); + expect(decoded.cast?.at(-1)?.name).toBe('Person 29'); + }); + + it('bounds genres to 20 entries', () => { + const decoded = JSON.parse( + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + genres: Array.from( + { length: 25 }, + (_, index) => `Genre ${index}` + ), + }) + ) as DownloadMetadataSnapshot; + + expect(decoded.genres).toHaveLength(20); + expect(decoded.genres?.at(-1)).toBe('Genre 19'); + }); + + it('rejects an allowed field beyond the fixed UTF-8 byte ceiling', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + plot: '🛰️'.repeat(DOWNLOAD_METADATA_MAX_BYTES), + }) + ).toThrow('Download metadata snapshot is too large'); + }); + + it('rejects a huge discarded array before walking its entries', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + ignored: Array.from({ length: 10_000 }, () => ({ + harmless: 'value', + })), + } as never) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects a huge discarded object before walking all its keys', () => { + const ignored = Object.fromEntries( + Array.from({ length: 10_000 }, (_, index) => [ + `harmless${index}`, + index, + ]) + ); + + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + ignored, + } as never) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects discarded data beyond the raw traversal depth budget', () => { + let ignored: Record = {}; + for (let depth = 0; depth < 50; depth += 1) { + ignored = { nested: ignored }; + } + + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + ignored, + } as never) + ).toThrow('Invalid download metadata snapshot'); + }); + + it('rejects discarded strings beyond the cumulative raw byte budget', () => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + ignored: 'x'.repeat(DOWNLOAD_METADATA_MAX_BYTES + 1), + } as never) + ).toThrow('Download metadata snapshot is too large'); + }); + + it.each(['url', 'headers', 'password', 'macAddress', 'cookie'])( + 'rejects a nested credential-bearing %s key', + (forbiddenKey) => { + const unsafe = { + ...validSnapshot, + ignored: { + deeper: [{ [forbiddenKey]: 'must-not-persist' }], + }, + }; + + expect(() => + encodeDownloadMetadataSnapshot(unsafe as never) + ).toThrow('Invalid download metadata snapshot'); + } + ); + + it.each([NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects unsafe numeric metadata %s', + (year) => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + year, + }) + ).toThrow('Invalid download metadata snapshot'); + } + ); + + it('trims display strings and drops unknown harmless fields', () => { + const encoded = encodeDownloadMetadataSnapshot({ + ...validSnapshot, + language: ' en ', + title: ' The Example ', + genres: [' Drama '], + ignored: 'not persisted', + } as never); + + expect(JSON.parse(encoded)).toEqual({ + ...validSnapshot, + language: 'en', + title: 'The Example', + genres: ['Drama'], + }); + }); + + it('accepts absolute credential-free TMDB and CDN artwork URLs', () => { + const snapshot: DownloadMetadataSnapshot = { + ...validSnapshot, + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + backdropUrl: + 'https://cdn.example.test/art/backdrop.webp?width=1280', + cast: [ + { + name: 'Actor', + profileUrl: 'https://image.tmdb.org/t/p/w185/profile.png', + }, + ], + episode: { + episodeNumber: 2, + seasonNumber: 1, + stillUrl: + 'https://cdn.example.test/stills/episode-2.jpeg?width=640', + }, + }; + + expect(JSON.parse(encodeDownloadMetadataSnapshot(snapshot))).toEqual( + snapshot + ); + }); + + it('accepts trusted extensionless artwork hosts and image endpoints', () => { + const snapshot: DownloadMetadataSnapshot = { + ...validSnapshot, + posterUrl: 'https://image.tmdb.org/t/p/w500/extensionless-poster', + backdropUrl: 'https://picsum.photos/1280/720', + cast: [ + { + name: 'Actor', + profileUrl: + 'https://cdn.example.test/api?action=get_image&id=profile-1', + }, + ], + episode: { + episodeNumber: 2, + seasonNumber: 1, + stillUrl: 'https://cdn.example.test/images/still/episode-2', + }, + }; + + expect(JSON.parse(encodeDownloadMetadataSnapshot(snapshot))).toEqual( + snapshot + ); + }); + + it('accepts artwork filenames containing title-like credential words', () => { + const snapshot: DownloadMetadataSnapshot = { + ...validSnapshot, + posterUrl: 'https://images.example.test/posters/secret-garden.jpg', + episode: { + episodeNumber: 9, + seasonNumber: 1, + stillUrl: 'https://images.example.test/stills/session-9.jpg', + }, + }; + + expect(JSON.parse(encodeDownloadMetadataSnapshot(snapshot))).toEqual( + snapshot + ); + }); + + it('normalizes blank optional artwork sentinels as absent', () => { + expect( + JSON.parse( + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + posterUrl: ' ', + backdropUrl: '\t', + cast: [{ name: 'Actor', profileUrl: '\n' }], + episode: { + episodeNumber: 2, + seasonNumber: 1, + stillUrl: ' ', + }, + }) + ) + ).toEqual({ + ...validSnapshot, + cast: [{ name: 'Actor' }], + episode: { + episodeNumber: 2, + seasonNumber: 1, + }, + }); + }); + + it.each([ + [ + 'poster userinfo', + { + posterUrl: + 'https://viewer:secret@images.example.test/poster.jpg', + }, + ], + [ + 'backdrop credential query', + { + backdropUrl: + 'https://images.example.test/backdrop.jpg?access_token=secret', + }, + ], + [ + 'person stream URL', + { + cast: [ + { + name: 'Actor', + profileUrl: + 'https://streams.example.test/channel/index.m3u8', + }, + ], + }, + ], + [ + 'episode session query', + { + episode: { + episodeNumber: 1, + seasonNumber: 1, + stillUrl: + 'https://images.example.test/still.jpg?sessionId=secret', + }, + }, + ], + [ + 'poster media URL', + { + posterUrl: 'https://media.example.test/movie.mp4', + }, + ], + [ + 'extensionless stream endpoint', + { + posterUrl: 'https://streams.example.test/live/123', + }, + ], + [ + 'URL fragment', + { + posterUrl: 'https://images.example.test/poster.jpg#overview', + }, + ], + [ + 'relative artwork URL', + { + posterUrl: '/images/poster.jpg', + }, + ], + [ + 'non-http artwork URL', + { + posterUrl: 'ftp://images.example.test/poster.jpg', + }, + ], + ])('rejects unsafe artwork in %s', (_label, fields) => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + ...fields, + } as DownloadMetadataSnapshot) + ).toThrow('Invalid download metadata snapshot'); + }); + + it.each([ + 'access_token', + 'token', + 'password', + 'secret', + 'cookie', + 'session', + 'signature', + 'credential', + 'AUTH', + 'device_mac', + 'api-key', + 'access_token=secret', + 'token:abc', + ])('rejects credential-like artwork path token %s', (pathToken) => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + posterUrl: `https://images.example.test/${pathToken}/value/poster.jpg`, + }) + ).toThrow('Invalid download metadata snapshot'); + }); + + it.each([ + 'token', + 'AUTH', + 'api_key', + 'password', + 'device_mac', + 'cookie', + 'sessionId', + 'X-Amz-Signature', + 'client_secret', + 'credential', + ])('rejects credential-like artwork query key %s', (queryKey) => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + posterUrl: `https://images.example.test/poster.jpg?${queryKey}=secret`, + }) + ).toThrow('Invalid download metadata snapshot'); + }); + + it.each([ + 'https://images.example.test/username/account/poster.jpg', + 'https://images.example.test/poster.jpg?username=account', + ])('rejects username-bearing artwork at the canonical validator', (url) => { + expect(() => normalizeDownloadArtworkUrl(url)).toThrow( + 'Invalid download metadata snapshot' + ); + }); + + it('decodes malformed persisted JSON as absent metadata', () => { + expect(decodeDownloadMetadataSnapshot('{not-json')).toBeUndefined(); + }); + + it('decodes invalid persisted metadata as absent metadata', () => { + expect( + decodeDownloadMetadataSnapshot( + JSON.stringify({ ...validSnapshot, title: ' ' }) + ) + ).toBeUndefined(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts new file mode 100644 index 000000000..02631ac31 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts @@ -0,0 +1,257 @@ +import type { + DownloadEpisodeMetadata, + DownloadMetadataPerson, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; +import { + getNormalizedDownloadUrlIdentity, + normalizeDownloadArtworkUrl, +} from './download-artwork-url'; +import { assertSafeDownloadMetadataInput } from './download-metadata-input-safety'; + +export const DOWNLOAD_METADATA_MAX_BYTES = 128 * 1024; + +const MAX_PEOPLE = 30; +const MAX_GENRES = 20; + +function invalidSnapshot(): never { + throw new Error('Invalid download metadata snapshot'); +} + +function oversizedSnapshot(): never { + throw new Error('Download metadata snapshot is too large'); +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalidSnapshot(); + } + return value as Record; +} + +function requiredString(value: unknown): string { + if (typeof value !== 'string' || value.trim() === '') { + return invalidSnapshot(); + } + return value.trim(); +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + return invalidSnapshot(); + } + return value.trim(); +} + +function optionalInteger(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + return invalidSnapshot(); + } + return value; +} + +function optionalFiniteNumber(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= Number.MAX_SAFE_INTEGER + ) { + return value; + } + return invalidSnapshot(); +} + +function normalizeGenres(value: unknown): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + return invalidSnapshot(); + } + return value.slice(0, MAX_GENRES).map(requiredString); +} + +function normalizePerson(value: unknown): DownloadMetadataPerson { + const person = asRecord(value); + const tmdbPersonId = optionalInteger(person.tmdbPersonId); + const role = optionalString(person.role); + const profileUrl = normalizeDownloadArtworkUrl(person.profileUrl); + return { + ...(tmdbPersonId === undefined ? {} : { tmdbPersonId }), + name: requiredString(person.name), + ...(role === undefined ? {} : { role }), + ...(profileUrl === undefined ? {} : { profileUrl }), + }; +} + +function normalizePeople(value: unknown): DownloadMetadataPerson[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + return invalidSnapshot(); + } + return value.slice(0, MAX_PEOPLE).map(normalizePerson); +} + +function normalizeEpisode(value: unknown): DownloadEpisodeMetadata | undefined { + if (value === undefined) { + return undefined; + } + const episode = asRecord(value); + const title = optionalString(episode.title); + const plot = optionalString(episode.plot); + const stillUrl = normalizeDownloadArtworkUrl(episode.stillUrl); + const seasonNumber = optionalInteger(episode.seasonNumber); + const episodeNumber = optionalInteger(episode.episodeNumber); + if (seasonNumber === undefined || episodeNumber === undefined) { + return invalidSnapshot(); + } + return { + ...(title === undefined ? {} : { title }), + ...(plot === undefined ? {} : { plot }), + ...(stillUrl === undefined ? {} : { stillUrl }), + seasonNumber, + episodeNumber, + }; +} + +function normalizeDownloadMetadataSnapshot( + input: unknown +): DownloadMetadataSnapshot { + assertSafeDownloadMetadataInput(input, DOWNLOAD_METADATA_MAX_BYTES); + const source = asRecord(input); + if ( + source.version !== 1 || + (source.mediaKind !== 'movie' && source.mediaKind !== 'series') + ) { + return invalidSnapshot(); + } + + const originalTitle = optionalString(source.originalTitle); + const plot = optionalString(source.plot); + const releaseDate = optionalString(source.releaseDate); + const year = optionalInteger(source.year); + const durationMinutes = optionalInteger(source.durationMinutes); + const genres = normalizeGenres(source.genres); + const rating = optionalFiniteNumber(source.rating); + const status = optionalString(source.status); + const posterUrl = normalizeDownloadArtworkUrl(source.posterUrl); + const backdropUrl = normalizeDownloadArtworkUrl(source.backdropUrl); + const tmdbId = optionalInteger(source.tmdbId); + const providerCategoryId = optionalString(source.providerCategoryId); + const cast = normalizePeople(source.cast); + const creators = normalizePeople(source.creators); + const episode = normalizeEpisode(source.episode); + const enrichedAt = optionalString(source.enrichedAt); + + return { + version: 1, + language: requiredString(source.language), + mediaKind: source.mediaKind, + title: requiredString(source.title), + ...(originalTitle === undefined ? {} : { originalTitle }), + ...(plot === undefined ? {} : { plot }), + ...(releaseDate === undefined ? {} : { releaseDate }), + ...(year === undefined ? {} : { year }), + ...(durationMinutes === undefined ? {} : { durationMinutes }), + ...(genres === undefined ? {} : { genres }), + ...(rating === undefined ? {} : { rating }), + ...(status === undefined ? {} : { status }), + ...(posterUrl === undefined ? {} : { posterUrl }), + ...(backdropUrl === undefined ? {} : { backdropUrl }), + ...(tmdbId === undefined ? {} : { tmdbId }), + ...(providerCategoryId === undefined ? {} : { providerCategoryId }), + ...(cast === undefined ? {} : { cast }), + ...(creators === undefined ? {} : { creators }), + ...(episode === undefined ? {} : { episode }), + ...(enrichedAt === undefined ? {} : { enrichedAt }), + }; +} + +export function encodeDownloadMetadataSnapshot( + input: DownloadMetadataSnapshot +): string { + let snapshot: DownloadMetadataSnapshot; + try { + snapshot = normalizeDownloadMetadataSnapshot(input); + } catch (error) { + if ( + error instanceof Error && + error.message === 'Download metadata snapshot is too large' + ) { + throw error; + } + return invalidSnapshot(); + } + const encoded = JSON.stringify(snapshot); + if (Buffer.byteLength(encoded, 'utf8') > DOWNLOAD_METADATA_MAX_BYTES) { + return oversizedSnapshot(); + } + return encoded; +} + +export function assertDownloadMetadataMatchesContentType( + metadataSnapshot: DownloadMetadataSnapshot, + contentType: string +): void { + if ( + (contentType !== 'vod' && contentType !== 'episode') || + (contentType === 'vod' && metadataSnapshot.mediaKind !== 'movie') || + (contentType === 'episode' && metadataSnapshot.mediaKind !== 'series') + ) { + invalidSnapshot(); + } +} + +export function assertDownloadMetadataArtworkDiffersFromStream( + metadataSnapshot: DownloadMetadataSnapshot, + streamUrl: string +): void { + const streamIdentity = getNormalizedDownloadUrlIdentity(streamUrl); + if (!streamIdentity) { + invalidSnapshot(); + } + const artworkUrls = [ + metadataSnapshot.posterUrl, + metadataSnapshot.backdropUrl, + ...(metadataSnapshot.cast?.map((person) => person.profileUrl) ?? []), + ...(metadataSnapshot.creators?.map((person) => person.profileUrl) ?? + []), + metadataSnapshot.episode?.stillUrl, + ]; + if ( + artworkUrls.some( + (artworkUrl) => + artworkUrl !== undefined && + getNormalizedDownloadUrlIdentity(artworkUrl) === streamIdentity + ) + ) { + invalidSnapshot(); + } +} + +export function decodeDownloadMetadataSnapshot( + value: string | null | undefined +): DownloadMetadataSnapshot | undefined { + if ( + !value || + Buffer.byteLength(value, 'utf8') > DOWNLOAD_METADATA_MAX_BYTES + ) { + return undefined; + } + try { + return normalizeDownloadMetadataSnapshot(JSON.parse(value)); + } catch { + return undefined; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata-update.ts b/apps/electron-backend/src/app/events/database/download-metadata-update.ts new file mode 100644 index 000000000..bd33ad340 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata-update.ts @@ -0,0 +1,146 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { and, eq, sql } from 'drizzle-orm'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { + assertDownloadMetadataArtworkDiffersFromStream, + assertDownloadMetadataMatchesContentType, + decodeDownloadMetadataSnapshot, + encodeDownloadMetadataSnapshot, +} from './download-metadata-snapshot'; + +interface DownloadMetadataResult { + success: boolean; + error?: string; +} + +const VALIDATION_ERRORS = new Set([ + 'Invalid download metadata snapshot', + 'Download metadata snapshot is too large', +]); + +function getSafeUpdateError(error: unknown): string { + if (error instanceof Error && VALIDATION_ERRORS.has(error.message)) { + return error.message; + } + return 'Could not update download metadata'; +} + +function isValidSeriesId(value: number | null): value is number { + return Number.isSafeInteger(value) && (value ?? 0) > 0; +} + +function getStoredEpisode( + member: typeof schema.downloads.$inferSelect +): DownloadMetadataSnapshot['episode'] { + const current = decodeDownloadMetadataSnapshot(member.metadataSnapshot); + if (current?.episode) { + return current.episode; + } + if ( + member.seasonNumber === null || + member.episodeNumber === null || + !Number.isSafeInteger(member.seasonNumber) || + !Number.isSafeInteger(member.episodeNumber) + ) { + return undefined; + } + return { + episodeNumber: member.episodeNumber, + seasonNumber: member.seasonNumber, + title: member.title, + }; +} + +export async function updateDownloadMetadataRequest( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot +): Promise { + try { + const encoded = encodeDownloadMetadataSnapshot(metadataSnapshot); + const normalizedMetadataSnapshot = + decodeDownloadMetadataSnapshot(encoded); + if (!normalizedMetadataSnapshot) { + throw new Error('Invalid download metadata snapshot'); + } + const db = await getDatabase(); + return db.transaction((tx) => { + const row = tx + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1) + .get(); + if (!row) { + return { error: 'Download not found', success: false }; + } + assertDownloadMetadataMatchesContentType( + normalizedMetadataSnapshot, + row.contentType + ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + row.url + ); + + if ( + row.contentType !== 'episode' || + !isValidSeriesId(row.seriesXtreamId) + ) { + tx.update(schema.downloads) + .set({ + metadataSnapshot: encoded, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, row.id)) + .run(); + return { success: true }; + } + + const members = tx + .select() + .from(schema.downloads) + .where( + and( + eq(schema.downloads.playlistId, row.playlistId), + eq(schema.downloads.seriesXtreamId, row.seriesXtreamId), + eq(schema.downloads.contentType, row.contentType) + ) + ) + .all(); + const writes = members.map((member) => { + const episode = getStoredEpisode(member); + const memberSnapshot = { + ...normalizedMetadataSnapshot, + episode, + }; + assertDownloadMetadataArtworkDiffersFromStream( + memberSnapshot, + member.url + ); + return { + id: member.id, + metadataSnapshot: + encodeDownloadMetadataSnapshot(memberSnapshot), + }; + }); + + for (const write of writes) { + tx.update(schema.downloads) + .set({ + metadataSnapshot: write.metadataSnapshot, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, write.id)) + .run(); + } + return { success: true }; + }); + } catch (error) { + const safeError = getSafeUpdateError(error); + if (safeError === 'Could not update download metadata') { + console.error('[Downloads] Error updating metadata:', error); + } + return { error: safeError, success: false }; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts new file mode 100644 index 000000000..80179b758 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts @@ -0,0 +1,548 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { + getHandler, + mockEq, + mockGetDatabase, + setupDownloadsEventsHarness, +} from './downloads.test-helpers'; + +const movieSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Refreshed Movie', + plot: 'Stored for offline details.', +}; + +function createSingleRowDatabase(row: Record | undefined) { + const storedRow = + row === undefined + ? undefined + : { + url: 'https://streams.example.test/items/default.mp4', + ...row, + }; + const get = jest.fn(() => storedRow); + const limit = jest.fn(() => ({ get })); + const transactionSelect = jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })); + const run = jest.fn(); + const updateWhere = jest.fn(() => ({ run })); + const set = jest.fn(() => ({ where: updateWhere })); + const transactionUpdate = jest.fn(() => ({ set })); + const transaction = jest.fn( + (operation: (tx: Record) => unknown) => + operation({ + select: transactionSelect, + update: transactionUpdate, + }) + ); + const db = { + select: jest.fn(), + transaction, + update: jest.fn(), + }; + mockGetDatabase.mockResolvedValue(db); + return { + db, + get, + run, + set, + transaction, + transactionSelect, + transactionUpdate, + updateWhere, + }; +} + +function createGroupedDatabase( + representative: Record, + members: Array> +) { + const storedRepresentative = { + url: 'https://streams.example.test/episodes/representative.mp4', + ...representative, + }; + const storedMembers = members.map((member, index) => ({ + url: `https://streams.example.test/episodes/${index + 1}.mp4`, + ...member, + })); + const get = jest.fn(() => storedRepresentative); + const limit = jest.fn(() => ({ get })); + const all = jest.fn(() => storedMembers); + const groupWhere = jest.fn(() => ({ all })); + const transactionSelect = jest + .fn() + .mockImplementationOnce(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })) + .mockImplementationOnce(() => ({ + from: jest.fn(() => ({ where: groupWhere })), + })); + const run = jest.fn(); + const updateWhere = jest.fn<{ run: typeof run }, [unknown]>(() => ({ + run, + })); + const set = jest.fn< + { where: typeof updateWhere }, + [Record] + >(() => ({ where: updateWhere })); + const transactionUpdate = jest.fn(() => ({ set })); + const transaction = jest.fn( + (operation: (tx: Record) => unknown) => + operation({ + select: transactionSelect, + update: transactionUpdate, + }) + ); + const db = { + select: jest.fn(), + transaction, + update: jest.fn(), + }; + mockGetDatabase.mockResolvedValue(db); + return { + all, + db, + get, + groupWhere, + run, + set, + transaction, + transactionSelect, + transactionUpdate, + updateWhere, + }; +} + +describe('downloads events: managed metadata updates', () => { + beforeEach(async () => { + await setupDownloadsEventsHarness(); + }); + + it('updates only the managed movie id', async () => { + const schema = await import('../../database/schema'); + const database = createSingleRowDatabase({ + contentType: 'vod', + id: 42, + playlistId: 'playlist-a', + seriesXtreamId: null, + }); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, movieSnapshot) + ).resolves.toEqual({ success: true }); + + expect(database.set).toHaveBeenCalledWith( + expect.objectContaining({ + metadataSnapshot: JSON.stringify(movieSnapshot), + updatedAt: expect.anything(), + }) + ); + expect(mockEq).toHaveBeenCalledWith(schema.downloads.id, 42); + expect(mockEq).toHaveBeenCalledTimes(2); + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionSelect).toHaveBeenCalledTimes(1); + expect(database.transactionUpdate).toHaveBeenCalledTimes(1); + expect(database.updateWhere).toHaveBeenCalledTimes(1); + expect(database.run).toHaveBeenCalledTimes(1); + expect(database.db.select).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + + it('updates the authoritative episode group while preserving each episode', async () => { + const schema = await import('../../database/schema'); + const representative = { + contentType: 'episode', + id: 41, + playlistId: 'stored-playlist', + seriesXtreamId: 700, + }; + const members = [ + { + episodeNumber: 1, + id: 41, + metadataSnapshot: JSON.stringify({ + ...movieSnapshot, + mediaKind: 'series', + title: 'Old Parent', + episode: { + episodeNumber: 1, + seasonNumber: 2, + title: 'Member One', + }, + }), + seasonNumber: 2, + title: 'Stored One', + }, + { + episodeNumber: 2, + id: 42, + metadataSnapshot: JSON.stringify({ + ...movieSnapshot, + mediaKind: 'series', + title: 'Old Parent', + episode: { + episodeNumber: 2, + seasonNumber: 2, + title: 'Member Two', + }, + }), + seasonNumber: 2, + title: 'Stored Two', + }, + ]; + const database = createGroupedDatabase(representative, members); + const refreshed: DownloadMetadataSnapshot = { + ...movieSnapshot, + mediaKind: 'series', + title: 'Refreshed Parent', + episode: { + episodeNumber: 99, + seasonNumber: 9, + title: 'Representative Episode', + }, + }; + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, refreshed) + ).resolves.toEqual({ success: true }); + + expect(database.groupWhere).toHaveBeenCalledTimes(1); + expect(mockEq).toHaveBeenCalledWith( + schema.downloads.playlistId, + 'stored-playlist' + ); + expect(mockEq).toHaveBeenCalledWith( + schema.downloads.contentType, + 'episode' + ); + expect(mockEq).toHaveBeenCalledWith( + schema.downloads.seriesXtreamId, + 700 + ); + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.db.select).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + expect(database.transactionSelect).toHaveBeenCalledTimes(2); + expect(database.transactionUpdate).toHaveBeenCalledTimes(2); + expect(database.run).toHaveBeenCalledTimes(2); + for (const [write] of database.set.mock.calls) { + expect(write).toEqual( + expect.objectContaining({ updatedAt: expect.anything() }) + ); + } + + const encodedWrites = database.set.mock.calls.map( + ([write]) => + JSON.parse( + (write as { metadataSnapshot: string }).metadataSnapshot + ) as DownloadMetadataSnapshot + ); + expect(encodedWrites).toEqual([ + { + ...refreshed, + episode: { + episodeNumber: 1, + seasonNumber: 2, + title: 'Member One', + }, + }, + { + ...refreshed, + episode: { + episodeNumber: 2, + seasonNumber: 2, + title: 'Member Two', + }, + }, + ]); + }); + + it('synthesizes missing episode metadata from authoritative row fields', async () => { + const member = { + episodeNumber: 3, + id: 43, + metadataSnapshot: '{corrupt', + seasonNumber: 4, + title: 'Stored Episode Title', + }; + const database = createGroupedDatabase( + { + contentType: 'episode', + id: 43, + playlistId: 'playlist-a', + seriesXtreamId: 700, + }, + [member] + ); + const refreshed: DownloadMetadataSnapshot = { + ...movieSnapshot, + mediaKind: 'series', + title: 'Refreshed Parent', + }; + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 43, refreshed) + ).resolves.toEqual({ success: true }); + + const write = database.set.mock.calls[0][0] as { + metadataSnapshot: string; + }; + expect(JSON.parse(write.metadataSnapshot)).toEqual({ + ...refreshed, + episode: { + episodeNumber: 3, + seasonNumber: 4, + title: 'Stored Episode Title', + }, + }); + }); + + it('updates only an ungrouped episode', async () => { + const database = createSingleRowDatabase({ + contentType: 'episode', + id: 42, + playlistId: 'playlist-a', + seriesXtreamId: null, + }); + const snapshot: DownloadMetadataSnapshot = { + ...movieSnapshot, + mediaKind: 'series', + }; + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, snapshot) + ).resolves.toEqual({ success: true }); + + expect(database.set).toHaveBeenCalledTimes(1); + expect(database.updateWhere).toHaveBeenCalledTimes(1); + }); + + it('returns a stable missing-row failure without writing', async () => { + const database = createSingleRowDatabase(undefined); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 404, movieSnapshot) + ).resolves.toEqual({ + error: 'Download not found', + success: false, + }); + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + + it.each([ + ['vod', 'series'], + ['episode', 'movie'], + ] as const)( + 'rejects %s rows carrying %s metadata without writing', + async (contentType, mediaKind) => { + const database = createSingleRowDatabase({ + contentType, + id: 42, + playlistId: 'playlist-a', + seriesXtreamId: null, + }); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, { + ...movieSnapshot, + mediaKind, + }) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + } + ); + + it('rejects metadata artwork when the stored stream differs only by a fragment', async () => { + const artworkUrl = 'https://streams.example.test/images/live.jpg'; + const database = createSingleRowDatabase({ + contentType: 'vod', + id: 42, + playlistId: 'playlist-a', + seriesXtreamId: null, + url: `${artworkUrl}#player`, + }); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, { + ...movieSnapshot, + posterUrl: artworkUrl, + }) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + + it('rejects a group update before writing when artwork reuses a member stream URL', async () => { + const artworkUrl = 'https://streams.example.test/images/episode-2.jpg'; + const database = createGroupedDatabase( + { + contentType: 'episode', + id: 41, + playlistId: 'playlist-a', + seriesXtreamId: 700, + url: 'https://streams.example.test/episodes/1.mp4', + }, + [ + { + episodeNumber: 1, + id: 41, + metadataSnapshot: null, + seasonNumber: 1, + title: 'One', + url: 'https://streams.example.test/episodes/1.mp4', + }, + { + episodeNumber: 2, + id: 42, + metadataSnapshot: null, + seasonNumber: 1, + title: 'Two', + url: `${artworkUrl}#player`, + }, + ] + ); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, { + ...movieSnapshot, + mediaKind: 'series', + posterUrl: artworkUrl, + }) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transactionSelect).toHaveBeenCalledTimes(2); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + + it('rejects a grouped episode carrying movie metadata before loading members', async () => { + const database = createGroupedDatabase( + { + contentType: 'episode', + id: 41, + playlistId: 'playlist-a', + seriesXtreamId: 700, + }, + [] + ); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, movieSnapshot) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionSelect).toHaveBeenCalledTimes(1); + expect(database.all).not.toHaveBeenCalled(); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + }); + + it('rejects invalid and unsafe snapshots before accessing the database', async () => { + const invalid = { ...movieSnapshot, title: ' ' }; + const unsafe = { + ...movieSnapshot, + cast: [{ name: 'Actor', private: { cookie: 'secret' } }], + }; + + for (const snapshot of [invalid, unsafe]) { + const result = await getHandler('DOWNLOADS_UPDATE_METADATA')( + null, + 42, + snapshot + ); + expect(result).toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + } + expect(mockGetDatabase).not.toHaveBeenCalled(); + }); + + it('does not commit a partial group when a member write fails', async () => { + const representative = { + contentType: 'episode', + id: 41, + playlistId: 'playlist-a', + seriesXtreamId: 700, + }; + const members = [ + { + episodeNumber: 1, + id: 41, + metadataSnapshot: null, + seasonNumber: 1, + title: 'One', + }, + { + episodeNumber: 2, + id: 42, + metadataSnapshot: null, + seasonNumber: 1, + title: 'Two', + }, + ]; + const database = createGroupedDatabase(representative, members); + const pending: number[] = []; + const committed: number[] = []; + database.updateWhere.mockImplementation((predicate) => ({ + run: jest.fn(() => { + pending.push(predicate as number); + if (pending.length === 2) { + throw new Error('disk full'); + } + }), + })); + database.transaction.mockImplementation((operation) => { + operation({ + select: database.transactionSelect, + update: database.transactionUpdate, + }); + committed.push(...pending); + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, { + ...movieSnapshot, + mediaKind: 'series', + }) + ).resolves.toEqual({ + error: 'Could not update download metadata', + success: false, + }); + } finally { + consoleError.mockRestore(); + } + + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(committed).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts new file mode 100644 index 000000000..1514e50b2 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts @@ -0,0 +1,201 @@ +interface SetupOptions { + accessError?: Error; + cleanupError?: Error; + claim?: { changes: number }; + fileReappeared?: boolean; + row?: Record | null; + urlError?: Error; +} + +async function setup(options: SetupOptions = {}) { + jest.resetModules(); + + const row = + options.row === null + ? null + : { + filePath: '/downloads/movie.mp4', + id: 42, + requestHeaders: JSON.stringify({ + Authorization: 'not-allowed', + Referer: 'https://example.test/', + 'User-Agent': 'IPTVnator', + }), + status: 'completed', + url: 'https://example.test/movie.mp4', + ...options.row, + }; + const limit = jest.fn().mockResolvedValue(row ? [row] : []); + const where = jest.fn().mockResolvedValue(options.claim ?? { changes: 1 }); + const set = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + update: jest.fn(() => ({ set })), + }; + const accessSync = jest.fn(() => { + if (options.accessError) { + throw options.accessError; + } + }); + const lstatSync = jest.fn(() => { + if (!options.fileReappeared) { + throw new Error('ENOENT'); + } + return { + isFile: () => true, + isSymbolicLink: () => false, + }; + }); + const assertRemoteUrlAllowed = jest.fn(async () => { + if (options.urlError) { + throw options.urlError; + } + }); + const removePartialDownloadFile = jest.fn(() => { + if (options.cleanupError) { + throw options.cleanupError; + } + }); + const enqueueDownload = jest.fn(); + + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + accessSync, + lstatSync, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ assertRemoteUrlAllowed })); + jest.doMock('./download-file-path', () => ({ removePartialDownloadFile })); + jest.doMock('./download-runtime', () => ({ enqueueDownload })); + + const { redownloadMissingRequest } = await import('./download-redownload'); + return { + accessSync, + assertRemoteUrlAllowed, + enqueueDownload, + lstatSync, + redownloadMissingRequest, + removePartialDownloadFile, + set, + where, + }; +} + +describe('redownload missing completed file', () => { + it('requeues the managed row at its retained destination', async () => { + const harness = await setup(); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + success: true, + }); + expect(harness.set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + errorMessage: null, + resumeValidator: null, + status: 'queued', + totalBytes: null, + }) + ); + expect(harness.enqueueDownload).toHaveBeenCalledWith({ + directory: '/downloads', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + headers: { + Referer: 'https://example.test/', + 'User-Agent': 'IPTVnator', + }, + id: 42, + resumeValidator: null, + totalBytes: null, + url: 'https://example.test/movie.mp4', + }); + }); + + it('recovers a file that reappeared without updating or enqueueing', async () => { + const harness = await setup({ fileReappeared: true }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + recovered: true, + success: true, + }); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects a row that is no longer completed', async () => { + const harness = await setup({ row: { status: 'queued' } }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Can only re-download missing completed files', + success: false, + }); + expect(harness.lstatSync).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects an unknown row', async () => { + const harness = await setup({ row: null }); + + await expect(harness.redownloadMissingRequest(404)).resolves.toEqual({ + error: 'Download not found', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing retained path', { row: { filePath: null } }], + [ + 'unavailable retained directory', + { accessError: new Error('EACCES') }, + ], + ])('rejects an %s', async (_label, options) => { + const harness = await setup(options); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Download folder is unavailable', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('does not mutate or enqueue when the remote URL is unsafe', async () => { + const unsafe = new Error('Blocked private destination'); + const harness = await setup({ urlError: unsafe }); + + await expect(harness.redownloadMissingRequest(42)).rejects.toBe(unsafe); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.removePartialDownloadFile).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('keeps the completed row when partial cleanup fails', async () => { + const harness = await setup({ + cleanupError: new Error('EPERM: locked'), + }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Could not delete the previous partial file', + success: false, + }); + expect(harness.set).not.toHaveBeenCalled(); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('does not enqueue after losing the conditional completed-row claim', async () => { + const harness = await setup({ claim: { changes: 0 } }); + + await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({ + error: 'Download is no longer recoverable', + success: false, + }); + expect(harness.enqueueDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-redownload.ts b/apps/electron-backend/src/app/events/database/download-redownload.ts new file mode 100644 index 000000000..b34f49f2c --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-redownload.ts @@ -0,0 +1,112 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { accessSync, constants } from 'node:fs'; +import { basename, dirname } from 'node:path'; +import { getDatabase } from '../../database/connection'; +import * as schema from '../../database/schema'; +import { assertRemoteUrlAllowed } from '../url-safety'; +import { isAvailableDownloadFile } from './download-file-availability'; +import { removePartialDownloadFile } from './download-file-path'; +import { parseStoredHeaders } from './download-requests'; +import { enqueueDownload } from './download-runtime'; + +export interface RedownloadMissingResult { + error?: string; + recovered?: boolean; + success: boolean; +} + +function isWritableDirectory(directory: string): boolean { + try { + accessSync(directory, constants.W_OK); + return true; + } catch { + return false; + } +} + +function hasNoChanges(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + 'changes' in result && + (result as { changes: number }).changes === 0 + ); +} + +export async function redownloadMissingRequest( + downloadId: number +): Promise { + const db = await getDatabase(); + const rows = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + const item = rows[0]; + + if (!item) { + return { error: 'Download not found', success: false }; + } + if (item.status !== 'completed') { + return { + error: 'Can only re-download missing completed files', + success: false, + }; + } + if (isAvailableDownloadFile(item.filePath)) { + return { recovered: true, success: true }; + } + if (!item.filePath || !isWritableDirectory(dirname(item.filePath))) { + return { error: 'Download folder is unavailable', success: false }; + } + + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + + try { + removePartialDownloadFile(item.filePath); + } catch (error) { + console.error( + '[Downloads] Failed to delete partial before missing-file recovery:', + error + ); + return { + error: 'Could not delete the previous partial file', + success: false, + }; + } + + const claim = await db + .update(schema.downloads) + .set({ + bytesDownloaded: 0, + errorMessage: null, + resumeValidator: null, + status: 'queued', + totalBytes: null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and( + eq(schema.downloads.id, item.id), + eq(schema.downloads.status, 'completed') + ) + ); + if (hasNoChanges(claim)) { + return { + error: 'Download is no longer recoverable', + success: false, + }; + } + + enqueueDownload({ + directory: dirname(item.filePath), + fileName: basename(item.filePath), + filePath: item.filePath, + headers: parseStoredHeaders(item.requestHeaders), + id: item.id, + resumeValidator: null, + totalBytes: null, + url: item.url, + }); + return { success: true }; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 68835167e..4f6cd4650 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -1,5 +1,388 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; import type { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +const metadataSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Offline Movie', +}; + +async function setupStartMetadataRequest( + existing: Record | undefined +) { + jest.resetModules(); + const schema = await import('../../database/schema'); + const playlistLimit = jest.fn().mockResolvedValue([{ id: 'playlist-1' }]); + const downloadLimit = jest + .fn() + .mockResolvedValue(existing ? [existing] : []); + const from = jest.fn((table: unknown) => ({ + where: jest.fn(() => ({ + limit: table === schema.playlists ? playlistLimit : downloadLimit, + })), + })); + const insertValues = jest.fn().mockResolvedValue({ lastInsertRowid: 84 }); + const set = jest.fn<{ where: jest.Mock }, [Record]>( + () => ({ + where: jest.fn().mockResolvedValue(undefined), + }) + ); + const db = { + insert: jest.fn(() => ({ values: insertValues })), + select: jest.fn(() => ({ + from, + })), + update: jest.fn(() => ({ set })), + }; + const enqueueDownload = jest.fn(); + const authorizer = { + requireAuthorized: jest.fn(async (directory: string) => directory), + } as unknown as DownloadDirectoryAuthorizer; + + jest.doMock('../../database/connection', () => ({ + getDatabase: jest.fn().mockResolvedValue(db), + })); + jest.doMock('../url-safety', () => ({ + assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('./download-runtime', () => ({ + enqueueDownload, + })); + + const { startDownloadRequest } = await import('./download-requests'); + return { + authorizer, + db, + enqueueDownload, + insertValues, + set, + startDownloadRequest, + }; +} + +function startPayload( + snapshot?: DownloadMetadataSnapshot, + contentType: 'vod' | 'episode' = 'vod' +) { + return { + contentType, + downloadFolder: '/downloads', + metadataSnapshot: snapshot, + playlistId: 'playlist-1', + title: 'Offline Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }; +} + +describe('download request metadata snapshots', () => { + it('persists an encoded snapshot for a new download', async () => { + const request = await setupStartMetadataRequest(undefined); + + await expect( + request.startDownloadRequest( + startPayload(metadataSnapshot), + request.authorizer + ) + ).resolves.toEqual({ id: 84, success: true }); + + expect(request.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + metadataSnapshot: JSON.stringify(metadataSnapshot), + }) + ); + }); + + it('preserves stored metadata when a restart omits a snapshot', async () => { + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + metadataSnapshot: JSON.stringify(metadataSnapshot), + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest(startPayload(), request.authorizer) + ).resolves.toEqual({ id: 42, success: true }); + + expect(request.set).toHaveBeenCalledTimes(1); + expect(request.set.mock.calls[0][0]).not.toHaveProperty( + 'metadataSnapshot' + ); + }); + + it('replaces stored metadata when a restart supplies a snapshot', async () => { + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + metadataSnapshot: null, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }); + + await request.startDownloadRequest( + startPayload(metadataSnapshot), + request.authorizer + ); + + expect(request.set).toHaveBeenCalledWith( + expect.objectContaining({ + metadataSnapshot: JSON.stringify(metadataSnapshot), + }) + ); + }); + + it('rejects invalid metadata before mutating a download row', async () => { + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: 'https://example.test/movie.mp4', + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest( + startPayload({ + ...metadataSnapshot, + title: ' ', + }), + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + }); + + it.each([ + ['vod', 'series'], + ['episode', 'movie'], + ] as const)( + 'rejects a new %s row carrying %s metadata', + async (contentType, mediaKind) => { + const request = await setupStartMetadataRequest(undefined); + + await expect( + request.startDownloadRequest( + startPayload( + { ...metadataSnapshot, mediaKind }, + contentType + ), + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['episode', 'movie'], + ['vod', 'series'], + ] as const)( + 'rejects a stored %s restart carrying %s metadata', + async (contentType, mediaKind) => { + const request = await setupStartMetadataRequest({ + contentType, + filePath: null, + id: 42, + metadataSnapshot: null, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Item', + url: 'https://example.test/item.mp4', + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest( + startPayload( + { ...metadataSnapshot, mediaKind }, + contentType + ), + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + } + ); + + it.each<[string, 'vod' | 'episode', DownloadMetadataSnapshot]>([ + [ + 'poster', + 'vod', + { + ...metadataSnapshot, + posterUrl: 'https://streams.example.test/images/live.jpg', + }, + ], + [ + 'backdrop', + 'vod', + { + ...metadataSnapshot, + backdropUrl: 'https://streams.example.test/images/live.jpg', + }, + ], + [ + 'person profile', + 'vod', + { + ...metadataSnapshot, + cast: [ + { + name: 'Actor', + profileUrl: + 'https://streams.example.test/images/live.jpg', + }, + ], + }, + ], + [ + 'episode still', + 'episode', + { + ...metadataSnapshot, + mediaKind: 'series', + episode: { + episodeNumber: 1, + seasonNumber: 1, + stillUrl: 'https://streams.example.test/images/live.jpg', + }, + }, + ], + ])( + 'rejects a new download whose stream URL is reused as %s artwork', + async (_label, contentType, snapshot) => { + const request = await setupStartMetadataRequest(undefined); + const url = 'https://streams.example.test/images/live.jpg'; + + await expect( + request.startDownloadRequest( + { + ...startPayload(snapshot, contentType), + url, + }, + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + } + ); + + it('rejects a new download when its stream differs from artwork only by a fragment', async () => { + const artworkUrl = 'https://streams.example.test/images/live.jpg'; + const request = await setupStartMetadataRequest(undefined); + + await expect( + request.startDownloadRequest( + { + ...startPayload({ + ...metadataSnapshot, + posterUrl: artworkUrl, + }), + url: `${artworkUrl}#player`, + }, + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects a restart whose poster reuses the normalized stored stream URL', async () => { + const storedUrl = + 'https://STREAMS.example.test:443/images/section/../live.jpg#player'; + const posterUrl = 'https://streams.example.test/images/live.jpg'; + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + metadataSnapshot: null, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: storedUrl, + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest( + { + ...startPayload({ + ...metadataSnapshot, + posterUrl, + }), + url: 'https://replacement.example.test/movie.mp4', + }, + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + }); + + it('rejects a restart whose poster reuses the replacement stream URL', async () => { + const artworkUrl = 'https://replacement.example.test/images/live.jpg'; + const replacementUrl = `${artworkUrl}#player`; + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + metadataSnapshot: null, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: 'https://stored.example.test/movie.mp4', + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest( + { + ...startPayload({ + ...metadataSnapshot, + posterUrl: artworkUrl, + }), + url: replacementUrl, + }, + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + }); +}); + describe('download requests resume', () => { it('enqueues a paused download with stored headers and original target path', async () => { jest.resetModules(); @@ -129,12 +512,11 @@ describe('download requests resume', () => { url: 'https://example.test/movie.mp4', }; const limit = jest.fn().mockResolvedValue([row]); - const set = jest.fn< - { where: jest.Mock }, - [Record] - >(() => ({ - where: jest.fn().mockResolvedValue(undefined), - })); + const set = jest.fn<{ where: jest.Mock }, [Record]>( + () => ({ + where: jest.fn().mockResolvedValue(undefined), + }) + ); const db = { select: jest.fn(() => ({ from: jest.fn(() => ({ @@ -205,12 +587,11 @@ describe('download requests resume', () => { .fn() .mockResolvedValueOnce([{ id: 'playlist-1' }]) .mockResolvedValueOnce([failedRow]); - const set = jest.fn< - { where: jest.Mock }, - [Record] - >(() => ({ - where: jest.fn().mockResolvedValue(undefined), - })); + const set = jest.fn<{ where: jest.Mock }, [Record]>( + () => ({ + where: jest.fn().mockResolvedValue(undefined), + }) + ); const db = { select: jest.fn(() => ({ from: jest.fn(() => ({ @@ -319,9 +700,8 @@ describe('download requests resume', () => { .spyOn(console, 'error') .mockImplementation(() => undefined); try { - const { startDownloadRequest } = await import( - './download-requests' - ); + const { startDownloadRequest } = + await import('./download-requests'); await expect( startDownloadRequest( diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 515402aa9..5793ea0a4 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -1,3 +1,4 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; import { and, eq, sql } from 'drizzle-orm'; import { basename, dirname, extname } from 'node:path'; import { getDatabase } from '../../database/connection'; @@ -5,6 +6,12 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; +import { + assertDownloadMetadataArtworkDiffersFromStream, + assertDownloadMetadataMatchesContentType, + decodeDownloadMetadataSnapshot, + encodeDownloadMetadataSnapshot, +} from './download-metadata-snapshot'; import { enqueueDownload } from './download-runtime'; export interface StartDownloadRequest { @@ -14,6 +21,7 @@ export interface StartDownloadRequest { title: string; url: string; posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; downloadFolder: string; headers?: { userAgent?: string; referer?: string; origin?: string }; seriesXtreamId?: number; @@ -74,7 +82,7 @@ function serializeHeaders( const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const; -function parseStoredHeaders( +export function parseStoredHeaders( value: string | null ): Record | undefined { if (!value) { @@ -110,6 +118,20 @@ export async function startDownloadRequest( data: StartDownloadRequest, authorizer: DownloadDirectoryAuthorizer ): Promise<{ success: boolean; error?: string; id?: number }> { + const encodedMetadataSnapshot = + data.metadataSnapshot === undefined + ? undefined + : encodeDownloadMetadataSnapshot(data.metadataSnapshot); + const normalizedMetadataSnapshot = + encodedMetadataSnapshot === undefined + ? undefined + : decodeDownloadMetadataSnapshot(encodedMetadataSnapshot); + if ( + encodedMetadataSnapshot !== undefined && + normalizedMetadataSnapshot === undefined + ) { + throw new Error('Invalid download metadata snapshot'); + } console.log('[Downloads] Enqueue download:', data.title); const directory = await authorizer.requireAuthorized(data.downloadFolder); await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true }); @@ -124,21 +146,6 @@ export async function startDownloadRequest( .from(schema.playlists) .where(eq(schema.playlists.id, data.playlistId)) .limit(1); - if (existingPlaylist.length === 0) { - console.log( - '[Downloads] Creating playlist entry for:', - data.playlistId - ); - await db.insert(schema.playlists).values({ - id: data.playlistId, - macAddress: data.macAddress, - name: data.playlistName || 'Unknown Playlist', - serverUrl: data.serverUrl, - type: data.playlistType || 'stalker', - url: data.portalUrl, - }); - } - const existing = await db .select() .from(schema.downloads) @@ -155,6 +162,20 @@ export async function startDownloadRequest( if (existing.length > 0) { const item = existing[0]; + if (normalizedMetadataSnapshot) { + assertDownloadMetadataMatchesContentType( + normalizedMetadataSnapshot, + item.contentType + ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + item.url + ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + data.url + ); + } if (!['completed', 'failed', 'canceled'].includes(item.status)) { return { error: 'Download already in progress', @@ -190,6 +211,9 @@ export async function startDownloadRequest( errorMessage: null, fileName, filePath: null, + ...(encodedMetadataSnapshot === undefined + ? {} + : { metadataSnapshot: encodedMetadataSnapshot }), requestHeaders: serializeHeaders(headers), resumeValidator: null, status: 'queued', @@ -208,10 +232,36 @@ export async function startDownloadRequest( return { id: item.id, success: true }; } + if (normalizedMetadataSnapshot) { + assertDownloadMetadataMatchesContentType( + normalizedMetadataSnapshot, + data.contentType + ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + data.url + ); + } + if (existingPlaylist.length === 0) { + console.log( + '[Downloads] Creating playlist entry for:', + data.playlistId + ); + await db.insert(schema.playlists).values({ + id: data.playlistId, + macAddress: data.macAddress, + name: data.playlistName || 'Unknown Playlist', + serverUrl: data.serverUrl, + type: data.playlistType || 'stalker', + url: data.portalUrl, + }); + } + const result = await db.insert(schema.downloads).values({ contentType: data.contentType, episodeNumber: data.episodeNumber, fileName, + metadataSnapshot: encodedMetadataSnapshot, playlistId: data.playlistId, posterUrl: data.posterUrl, requestHeaders: serializeHeaders(headers), diff --git a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts index 51234846f..808251107 100644 --- a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts @@ -2,10 +2,12 @@ import { expectManagedPathLookup, getHandler, MANAGED_PATH_STATE, - mockExistsSync, + mockBroadcastDownloadUpdate, + mockLstatSync, mockManagedPath, mockOpenPath, mockPauseDownload, + mockRedownloadMissingRequest, mockResumeDownloadRequest, mockShowItemInFolder, setupDownloadsEventsHarness, @@ -71,6 +73,22 @@ describe('downloads events: pause, resume, and reveal', () => { ); }); + it('forwards missing-file recovery by managed download id', async () => { + mockRedownloadMissingRequest.mockResolvedValue({ + recovered: true, + success: true, + }); + + await expect( + getHandler('DOWNLOADS_REDOWNLOAD_MISSING')(null, 42) + ).resolves.toEqual({ + recovered: true, + success: true, + }); + expect(mockRedownloadMissingRequest).toHaveBeenCalledWith(42); + expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1); + }); + describe.each([ { channel: 'DOWNLOADS_REVEAL_FILE', @@ -85,7 +103,6 @@ describe('downloads events: pause, resume, and reveal', () => { ])('$operation managed-path boundary', ({ channel, filePath }) => { it('rejects an unmanaged database path before accessing the filesystem', async () => { const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED); - mockExistsSync.mockReturnValue(true); await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ error: 'File not found', @@ -93,14 +110,16 @@ describe('downloads events: pause, resume, and reveal', () => { }); expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockLstatSync).not.toHaveBeenCalled(); expect(mockOpenPath).not.toHaveBeenCalled(); expect(mockShowItemInFolder).not.toHaveBeenCalled(); }); it('rejects a managed database path that is missing from disk', async () => { const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(false); + mockLstatSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ error: 'File not found', @@ -108,15 +127,44 @@ describe('downloads events: pause, resume, and reveal', () => { }); expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(1); + expect(mockLstatSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'directory', + { + isFile: () => false, + isSymbolicLink: () => false, + }, + ], + [ + 'symbolic link', + { + isFile: () => true, + isSymbolicLink: () => true, + }, + ], + ])('rejects a managed %s', async (_label, stats) => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockLstatSync.mockReturnValue(stats); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockLstatSync).toHaveBeenCalledWith(filePath); expect(mockOpenPath).not.toHaveBeenCalled(); expect(mockShowItemInFolder).not.toHaveBeenCalled(); }); it('fails closed when the managed-path database query rejects', async () => { const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR); - mockExistsSync.mockReturnValue(true); const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -140,7 +188,7 @@ describe('downloads events: pause, resume, and reveal', () => { } expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockLstatSync).not.toHaveBeenCalled(); expect(mockOpenPath).not.toHaveBeenCalled(); expect(mockShowItemInFolder).not.toHaveBeenCalled(); }); @@ -149,15 +197,18 @@ describe('downloads events: pause, resume, and reveal', () => { it('reveals a managed file that exists on disk', async () => { const filePath = '/downloads/reveal-success.mp4'; const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(true); + mockLstatSync.mockReturnValue({ + isFile: () => true, + isSymbolicLink: () => false, + }); await expect( getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath) ).resolves.toEqual({ success: true }); expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(1); + expect(mockLstatSync).toHaveBeenCalledWith(filePath); expect(mockShowItemInFolder).toHaveBeenCalledTimes(1); expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath); expect(mockOpenPath).not.toHaveBeenCalled(); @@ -166,7 +217,10 @@ describe('downloads events: pause, resume, and reveal', () => { it('waits for the native shell before reporting a managed file as played', async () => { const filePath = '/downloads/play-success.mp4'; const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(true); + mockLstatSync.mockReturnValue({ + isFile: () => true, + isSymbolicLink: () => false, + }); let resolveOpenPath!: (value: string) => void; const openPathResult = new Promise((resolve) => { resolveOpenPath = resolve; @@ -183,8 +237,8 @@ describe('downloads events: pause, resume, and reveal', () => { await new Promise((resolve) => setImmediate(resolve)); expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(1); + expect(mockLstatSync).toHaveBeenCalledWith(filePath); expect(mockOpenPath).toHaveBeenCalledTimes(1); expect(mockOpenPath).toHaveBeenCalledWith(filePath); expect(mockShowItemInFolder).not.toHaveBeenCalled(); @@ -193,4 +247,48 @@ describe('downloads events: pause, resume, and reveal', () => { resolveOpenPath(''); await expect(response).resolves.toEqual({ success: true }); }); + + it('returns the native shell error when playback fails but the file remains available', async () => { + const filePath = '/downloads/play-shell-error.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockLstatSync.mockReturnValue({ + isFile: () => true, + isSymbolicLink: () => false, + }); + mockOpenPath.mockResolvedValue('No application can open this file'); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, filePath) + ).resolves.toEqual({ + error: 'No application can open this file', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(2); + }); + + it('reports a file-disappeared race when the native shell cannot open the path', async () => { + const filePath = '/downloads/play-disappeared.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockLstatSync + .mockReturnValueOnce({ + isFile: () => true, + isSymbolicLink: () => false, + }) + .mockImplementationOnce(() => { + throw new Error('ENOENT'); + }); + mockOpenPath.mockResolvedValue('The file does not exist'); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, filePath) + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockLstatSync).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts new file mode 100644 index 000000000..f6f0ba8d1 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -0,0 +1,214 @@ +import { + getHandler, + mockGetDatabase, + mockLstat, + mockLstatSync, + setupDownloadsEventsHarness, +} from './downloads.test-helpers'; + +function regularFile() { + return { + isFile: () => true, + isSymbolicLink: () => false, + }; +} + +function nextEventLoopTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('downloads events: file availability', () => { + beforeEach(async () => { + await setupDownloadsEventsHarness(); + }); + + it('loads list availability without synchronous filesystem probes', async () => { + const row = { + filePath: '/downloads/network-volume/movie.mp4', + id: 1, + status: 'completed', + }; + const orderBy = jest.fn().mockResolvedValue([row]); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ orderBy })), + })), + }); + mockLstat.mockResolvedValue(regularFile()); + mockLstatSync.mockImplementation(() => { + throw new Error('synchronous probe must not run'); + }); + + await expect(getHandler('DOWNLOADS_GET_LIST')(null)).resolves.toEqual([ + { + ...row, + metadataSnapshot: undefined, + fileAvailability: 'available', + }, + ]); + expect(mockLstat).toHaveBeenCalledWith(row.filePath); + expect(mockLstatSync).not.toHaveBeenCalled(); + }); + + it('deduplicates concurrent probes for the same completed file', async () => { + const rows = [1, 2].map((id) => ({ + filePath: '/downloads/shared/movie.mp4', + id, + status: 'completed', + })); + const orderBy = jest.fn().mockResolvedValue(rows); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ orderBy })), + })), + }); + let finishProbe!: (value: ReturnType) => void; + mockLstat.mockReturnValue( + new Promise((resolve) => { + finishProbe = resolve; + }) + ); + + const response = getHandler('DOWNLOADS_GET_LIST')(null); + await nextEventLoopTurn(); + + expect(mockLstat).toHaveBeenCalledTimes(1); + finishProbe(regularFile()); + await expect(response).resolves.toHaveLength(2); + }); + + it('starts at most four completed-file probes concurrently', async () => { + const rows = Array.from({ length: 6 }, (_, index) => ({ + filePath: `/downloads/network/movie-${index}.mp4`, + id: index + 1, + status: 'completed', + })); + const orderBy = jest.fn().mockResolvedValue(rows); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ orderBy })), + })), + }); + const finishProbes: Array< + (value: ReturnType) => void + > = []; + mockLstat.mockImplementation( + () => + new Promise((resolve) => { + finishProbes.push(resolve); + }) + ); + + const response = getHandler('DOWNLOADS_GET_LIST')(null); + await nextEventLoopTurn(); + + expect(mockLstat).toHaveBeenCalledTimes(4); + finishProbes.slice(0, 4).forEach((finish) => finish(regularFile())); + await nextEventLoopTurn(); + expect(mockLstat).toHaveBeenCalledTimes(6); + finishProbes.slice(4).forEach((finish) => finish(regularFile())); + await expect(response).resolves.toHaveLength(6); + }); + + it('decorates every download in the list from the current filesystem state', async () => { + const metadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Available Movie', + }; + const rows = [ + { + filePath: '/downloads/available.mp4', + id: 1, + metadataSnapshot: JSON.stringify(metadataSnapshot), + status: 'completed', + }, + { + filePath: '/downloads/missing.mp4', + id: 2, + status: 'completed', + }, + { + filePath: '/downloads/queued.mp4', + id: 3, + status: 'queued', + }, + ]; + const orderBy = jest.fn().mockResolvedValue(rows); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ orderBy })), + })), + }); + mockLstat.mockImplementation(async (filePath) => { + if (filePath === '/downloads/missing.mp4') { + throw new Error('ENOENT'); + } + return regularFile(); + }); + + await expect(getHandler('DOWNLOADS_GET_LIST')(null)).resolves.toEqual([ + { + ...rows[0], + metadataSnapshot, + fileAvailability: 'available', + }, + { + ...rows[1], + metadataSnapshot: undefined, + fileAvailability: 'missing', + }, + { + ...rows[2], + metadataSnapshot: undefined, + fileAvailability: 'not-applicable', + }, + ]); + expect(mockLstat).toHaveBeenCalledTimes(2); + }); + + it('decorates an individual download from the current filesystem state', async () => { + const row = { + filePath: '/downloads/missing.mp4', + id: 2, + metadataSnapshot: '{corrupt', + status: 'completed', + }; + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([row]), + })), + })), + })), + }); + mockLstat.mockImplementation(async () => { + throw new Error('ENOENT'); + }); + + await expect(getHandler('DOWNLOADS_GET')(null, 2)).resolves.toEqual({ + ...row, + metadataSnapshot: undefined, + fileAvailability: 'missing', + }); + }); + + it('keeps a missing individual download response nullable', async () => { + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([]), + })), + })), + })), + }); + + await expect( + getHandler('DOWNLOADS_GET')(null, 404) + ).resolves.toBeNull(); + expect(mockLstat).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index 57c8e7ae0..bdd86ebb6 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -1,18 +1,24 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; import { and, eq, inArray } from 'drizzle-orm'; import { app, dialog, ipcMain, shell } from 'electron'; -import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; +import { + decorateDownloadItemAsync, + isAvailableDownloadFile, +} from './download-file-availability'; import { removePartialDownloadFile } from './download-file-path'; +import { updateDownloadMetadataRequest } from './download-metadata-update'; import { resumeDownloadRequest, retryDownloadRequest, startDownloadRequest, type StartDownloadRequest, } from './download-requests'; +import { redownloadMissingRequest } from './download-redownload'; import { resetStaleDownloads } from './download-recovery'; import { broadcastDownloadUpdate, @@ -159,6 +165,17 @@ ipcMain.handle( } ); +ipcMain.handle( + 'DOWNLOADS_REDOWNLOAD_MISSING', + async (_event, downloadId: number) => { + const result = await redownloadMissingRequest(downloadId); + if (result.recovered) { + broadcastDownloadUpdate(); + } + return result; + } +); + ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { try { console.log('[Downloads] Remove download:', downloadId); @@ -167,7 +184,7 @@ ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { .select({ filePath: schema.downloads.filePath, status: schema.downloads.status, - }) + }) .from(schema.downloads) .where(eq(schema.downloads.id, downloadId)) .limit(1); @@ -207,11 +224,12 @@ ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => { try { const db = await getDatabase(); const query = db.select().from(schema.downloads); - return playlistId + const rows = await (playlistId ? query .where(eq(schema.downloads.playlistId, playlistId)) .orderBy(schema.downloads.createdAt) - : query.orderBy(schema.downloads.createdAt); + : query.orderBy(schema.downloads.createdAt)); + return Promise.all(rows.map((row) => decorateDownloadItemAsync(row))); } catch (error) { console.error('[Downloads] Error getting download list:', error); throw error; @@ -226,13 +244,22 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => { .from(schema.downloads) .where(eq(schema.downloads.id, downloadId)) .limit(1); - return result[0] || null; + return result[0] ? await decorateDownloadItemAsync(result[0]) : null; } catch (error) { console.error('[Downloads] Error getting download:', error); throw error; } }); +ipcMain.handle( + 'DOWNLOADS_UPDATE_METADATA', + async ( + _event, + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot + ) => updateDownloadMetadataRequest(downloadId, metadataSnapshot) +); + ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => { return downloadDirectoryAuthorizer.getPreferredDirectory(); }); @@ -252,7 +279,10 @@ ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => { }); ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => { - if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) { + if ( + !(await isManagedDownloadFile(filePath)) || + !isAvailableDownloadFile(filePath) + ) { return { error: 'File not found', success: false }; } shell.showItemInFolder(filePath); @@ -260,11 +290,19 @@ ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => { }); ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => { - if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) { + if ( + !(await isManagedDownloadFile(filePath)) || + !isAvailableDownloadFile(filePath) + ) { return { error: 'File not found', success: false }; } - await shell.openPath(filePath); - return { success: true }; + const error = await shell.openPath(filePath); + if (!error) { + return { success: true }; + } + return isAvailableDownloadFile(filePath) + ? { error, success: false } + : { error: 'File not found', success: false }; }); ipcMain.handle( @@ -278,7 +316,10 @@ ipcMain.handle( 'canceled', ]); const terminalFilter = playlistId - ? and(eq(schema.downloads.playlistId, playlistId), terminalStatus) + ? and( + eq(schema.downloads.playlistId, playlistId), + terminalStatus + ) : terminalStatus; const rows = await db .select({ diff --git a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts index 21f2dd4ab..a112697ba 100644 --- a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts +++ b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts @@ -14,8 +14,10 @@ export const mockRemoveDownloadFromRuntime = jest.fn(); export const mockBroadcastDownloadUpdate = jest.fn(); export const mockRemovePartialDownloadFile = jest.fn(); export const mockPauseDownload = jest.fn(); +export const mockRedownloadMissingRequest = jest.fn(); export const mockResumeDownloadRequest = jest.fn(); -export const mockExistsSync = jest.fn(); +export const mockLstat = jest.fn(); +export const mockLstatSync = jest.fn(); export const mockOpenPath = jest.fn(); export const mockShowItemInFolder = jest.fn(); export const mockEq = jest.fn(); @@ -54,15 +56,23 @@ export async function setupDownloadsEventsHarness(): Promise { mockBroadcastDownloadUpdate.mockReset(); mockRemovePartialDownloadFile.mockReset(); mockPauseDownload.mockReset(); + mockRedownloadMissingRequest.mockReset(); mockResumeDownloadRequest.mockReset(); - mockExistsSync.mockReset(); + mockLstat.mockReset(); + mockLstatSync.mockReset(); mockOpenPath.mockReset().mockResolvedValue(''); mockShowItemInFolder.mockReset(); mockEq.mockReset(); jest.doMock('node:fs', () => ({ ...jest.requireActual('node:fs'), - existsSync: mockExistsSync, + lstatSync: mockLstatSync, + })); + jest.doMock('node:fs/promises', () => ({ + ...jest.requireActual( + 'node:fs/promises' + ), + lstat: mockLstat, })); jest.doMock('drizzle-orm', () => { const actual = @@ -98,6 +108,9 @@ export async function setupDownloadsEventsHarness(): Promise { jest.doMock('./download-file-path', () => ({ removePartialDownloadFile: mockRemovePartialDownloadFile, })); + jest.doMock('./download-redownload', () => ({ + redownloadMissingRequest: mockRedownloadMissingRequest, + })); jest.doMock('./download-runtime', () => ({ broadcastDownloadUpdate: mockBroadcastDownloadUpdate, cancelDownload: jest.fn(), @@ -147,14 +160,17 @@ export function expectManagedPathLookup( expect(mockEq).toHaveBeenCalledTimes(1); expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); expect(mockEq.mock.calls[0][1]).toBe(filePath); - expect( - lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value - ).toBe(true); + expect(lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value).toBe( + true + ); expect(lookup.limit).toHaveBeenCalledTimes(1); expect(lookup.limit).toHaveBeenCalledWith(1); } -export function mockDownloadRow(row: { filePath: string | null; status: string }) { +export function mockDownloadRow(row: { + filePath: string | null; + status: string; +}) { const deleteWhere = jest.fn().mockResolvedValue(undefined); const db = { delete: jest.fn(() => ({ where: deleteWhere })), diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 73361f1fc..0fd1f60c6 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -111,6 +111,13 @@ export const routes: Routes = [ (c) => c.GlobalSearchResultsComponent ), }, + { + path: 'downloads/:downloadId', + loadComponent: () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadOfflineDetailComponent + ), + }, { path: 'downloads', loadComponent: () => diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ff289e07b..985a404b2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1084,8 +1084,75 @@ "COMPLETED": "مكتمل", "FAILED": "فشل", "CANCELED": "ملغى", - "PAUSED": "متوقف مؤقتًا" - } + "PAUSED": "متوقف مؤقتًا", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 977afedec..063ce9b93 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1084,8 +1084,75 @@ "COMPLETED": "كمل", "FAILED": "فشل", "CANCELED": "تلغى", - "PAUSED": "موقوف مؤقتاً" - } + "PAUSED": "موقوف مؤقتاً", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 67a7f21fc..de8a54ec2 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Завершана", "FAILED": "Няўдала", "CANCELED": "Скасавана", - "PAUSED": "На паўзе" - } + "PAUSED": "На паўзе", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Аддаленае кіраванне", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index abe7f8414..79ba1139e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Abgeschlossen", "FAILED": "Fehlgeschlagen", "CANCELED": "Abgebrochen", - "PAUSED": "Pausiert" - } + "PAUSED": "Pausiert", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Fernbedienung", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a7622308d..60460a449 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Ολοκληρώθηκε", "FAILED": "Απέτυχε", "CANCELED": "Ακυρώθηκε", - "PAUSED": "Σε παύση" - } + "PAUSED": "Σε παύση", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Τηλεχειριστήριο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 42fc881fd..7821ca2f8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Completed", "FAILED": "Failed", "CANCELED": "Canceled", - "PAUSED": "Paused" - } + "PAUSED": "Paused", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Remote control", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 88e13f1b5..1c678c123 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Completado", "FAILED": "Fallido", "CANCELED": "Cancelado", - "PAUSED": "En pausa" - } + "PAUSED": "En pausa", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Control remoto", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 0579ed2c4..2806f61cd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Terminé", "FAILED": "Échec", "CANCELED": "Annulé", - "PAUSED": "En pause" - } + "PAUSED": "En pause", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Télécommande", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index ea101b39c..8800b87a5 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Befejeződött", "FAILED": "Sikertelen", "CANCELED": "Megszakítva", - "PAUSED": "Paused" - } + "PAUSED": "Paused", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Távvezérlés", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 721069d00..37e6aad3e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Completato", "FAILED": "Non riuscito", "CANCELED": "Annullato", - "PAUSED": "In pausa" - } + "PAUSED": "In pausa", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Controllo remoto", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 8612ef627..ac99cd73b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1084,8 +1084,75 @@ "COMPLETED": "完了", "FAILED": "失敗", "CANCELED": "キャンセル済み", - "PAUSED": "一時停止中" - } + "PAUSED": "一時停止中", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "リモートコントロール", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d8f86129d..eb96e5988 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1084,8 +1084,75 @@ "COMPLETED": "완료됨", "FAILED": "실패", "CANCELED": "취소됨", - "PAUSED": "일시정지됨" - } + "PAUSED": "일시정지됨", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "원격 제어", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index f6996b795..304037f57 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Voltooid", "FAILED": "Mislukt", "CANCELED": "Geannuleerd", - "PAUSED": "Gepauzeerd" - } + "PAUSED": "Gepauzeerd", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Afstandsbediening", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index eb5b4791b..b12587b2d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Ukończone", "FAILED": "Niepowodzenie", "CANCELED": "Anulowane", - "PAUSED": "Wstrzymane" - } + "PAUSED": "Wstrzymane", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Zdalne sterowanie", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f6aff24ab..568d7c8d1 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Concluído", "FAILED": "Falhou", "CANCELED": "Cancelado", - "PAUSED": "Pausado" - } + "PAUSED": "Pausado", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Controle remoto", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 430a57b70..7d7d470a5 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Завершено", "FAILED": "Ошибка", "CANCELED": "Отменено", - "PAUSED": "На паузе" - } + "PAUSED": "На паузе", + "FILE_MISSING": "Файл отсутствует" + }, + "ACTIVE_COUNT": "{{count}} активн.", + "TRACKED_DOWNLOADS": "Объём загрузок", + "CLEAR_FINISHED": "Очистить завершённые", + "DOWNLOADING_NOW": "Скачиваются сейчас", + "NEEDS_ATTENTION": "Требуют внимания", + "READY_TO_WATCH": "Готово к просмотру", + "OFFLINE": "Офлайн", + "OFFLINE_BADGE": "Офлайн", + "FILTER": { + "LABEL": "Фильтр загрузок", + "ALL": "Все", + "MOVIES": "Фильмы", + "SERIES": "Сериалы", + "IN_PROGRESS": "В процессе" + }, + "REMOVE_FROM_MANAGER": "Убрать из менеджера", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Убрать из менеджера?", + "MESSAGE": "Запись исчезнет из IPTVnator, но скачанный медиафайл останется на диске." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Удалить частичную загрузку?", + "MESSAGE": "Запись и сохранённая часть загрузки будут удалены. Возобновить её больше не получится." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Очистить завершённые загрузки?", + "MESSAGE": "Готовые медиафайлы останутся на диске. Частичные данные неудачных и отменённых загрузок будут удалены." + }, + "ARTWORK_UNAVAILABLE": "Обложка недоступна", + "EPISODE_COUNT_ONE": "1 эпизод", + "EPISODE_COUNT_OTHER": "{{count}} эп.", + "MORE_ACTIONS": "Другие действия", + "MOVIE": "Фильм", + "EPISODE": "Эпизод", + "SERIES": "Сериал", + "OPEN_ARTWORK": "Открыть {{title}}", + "OPEN_DETAILS": "Открыть подробности", + "OPEN_EPISODES": "Открыть скачанные эпизоды", + "SEASON": "Сезон {{season}}", + "SEASON_RANGE": "Сезоны {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Скачанные эпизоды", + "OFFLINE_DETAIL": { + "AVAILABLE": "Доступно офлайн", + "PLAY": "Воспроизвести офлайн", + "VIEW_IN_PORTAL": "Открыть в портале", + "VIEW_IN_PORTAL_UNAVAILABLE": "Контент больше недоступен в исходном портале", + "DOWNLOADED_EPISODES": "Скачано эпизодов: {{count}}", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Загрузка не найдена", + "NOT_FOUND_BODY": "Эта загрузка больше недоступна.", + "BACK": "Назад к загрузкам" + }, + "ARIA": { + "OPEN": "Открыть {{title}}", + "POSTER_UNAVAILABLE": "Обложка для {{title}} недоступна", + "PROGRESS": "Ход загрузки {{title}}", + "PAUSE": "Приостановить {{title}}", + "CANCEL": "Отменить {{title}}", + "RESUME": "Возобновить {{title}}", + "REMOVE": "Убрать {{title}} из менеджера", + "RETRY": "Повторить {{title}}", + "MORE_ACTIONS": "Другие действия для {{title}}", + "COPY_URL": "Копировать URL загрузки {{title}}", + "DOWNLOAD_AGAIN": "Скачать {{title}} заново" + }, + "DOWNLOAD_AGAIN": "Скачать заново" }, "REMOTE_CONTROL": { "TITLE": "Дистанционное управление", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 5baba7d7e..41defae57 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1084,8 +1084,75 @@ "COMPLETED": "Tamamlandı", "FAILED": "Başarısız", "CANCELED": "İptal Edildi", - "PAUSED": "Duraklatıldı" - } + "PAUSED": "Duraklatıldı", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Uzaktan kontrol", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4d3162d0c..1734eb2ec 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1084,8 +1084,75 @@ "COMPLETED": "已完成", "FAILED": "失败", "CANCELED": "已取消", - "PAUSED": "已暂停" - } + "PAUSED": "已暂停", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "远程控制", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f7f479b1e..ac83eed93 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1084,8 +1084,75 @@ "COMPLETED": "已完成", "FAILED": "失敗", "CANCELED": "已取消", - "PAUSED": "已暫停" - } + "PAUSED": "已暫停", + "FILE_MISSING": "File missing" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "OFFLINE_DETAIL": { + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" + }, + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "遠端控制", diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 3217951e6..42825f6fc 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -1,6 +1,11 @@ # Download Manager Architecture -The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal views. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling. +The download manager is a desktop-only feature that layers a curated queue, +progress tracking, storage configuration, and playback controls on top of the +existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal +views. Backend work is handled in the Electron process while the Angular +renderer exposes the global `/workspace/downloads` page, source-scoped route +variants, contextual buttons, and theme-aware styling. ## Backend responsibilities @@ -24,16 +29,127 @@ The download manager is a desktop-only feature that layers a curated queue, prog without re-downloading); pause and restart recovery keep it for a later resume. Re-downloading such a failed row from a detail page (`DOWNLOADS_START`) deletes the retained `.part` before the row is reset. +- **Derived file readiness and recovery** + `DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on + every read. Only regular, non-symbolic-link files are reported as available; + missing paths, directories, symlinks, and inspection errors are reported as + missing without changing the persisted `completed` transfer status. + `DOWNLOADS_REDOWNLOAD_MISSING` accepts only the managed row id, rechecks the + file, and returns a recovered result without network access when it has + reappeared. Otherwise it conditionally claims the completed row, preserves + its owned destination, re-applies the stored header allowlist and URL safety + policy, and enqueues a fresh transfer without overwriting an existing file. - **IPC surface** - The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/pause/resume/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. + The backend exposes `DOWNLOADS_*` handlers for list retrieval, + start/pause/resume/cancel/retry/missing-file recovery/remove operations, + folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the + renderer listens to in order to refresh its signal store. ## Renderer architecture - **Downloads service** (`libs/services/src/lib/downloads.service.ts`) - Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download/resume the service asks the main process for the authorized folder and calls the download IPC command. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `pauseDownload`, `resumeDownload`, `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. -- **Downloads view** (`libs/portal/downloads/feature`) - A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook. - Failed/canceled cards show retry/delete controls, queued/downloading cards show pause/cancel controls, paused cards show resume/cancel/delete controls, and completed cards render inline play/open buttons with `mat-icon` cues. Pause/resume/cancel/retry surface backend `success: false` results in a snackbar instead of failing silently. The header also shows the resolved download folder and a `CHANGE FOLDER` action. VOD and episode detail views render a paused download as an active "Resume" button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`) rather than a disabled "Downloading" state. + `DownloadsService.downloads` is the renderer's authoritative **global** list. + `loadDownloads()` therefore always invokes the Electron list IPC without its + legacy optional playlist scope. Route scope, category, and search must never + replace or narrow that signal. Overlapping loads are request-ordered so a + late response cannot replace a newer snapshot. Before each fresh download or + resume the service asks the main process for an authorized folder and calls + the corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a + new global load. +- **Pure manager model** + (`download-manager.viewmodel.ts` and `download-library.viewmodel.ts`) + derives the current route scope, search/category filtering, queue partitions, + stable ordering, counts, and tracked byte total without mutating service + state. `queued`, `downloading`, and `paused` rows form the active surface; + `failed` and `canceled` rows form the attention surface. A `completed` row + whose derived file availability is missing also enters attention with a + dedicated recovery reason; only available completed rows enter the offline + library. Completed episodes with a valid + `seriesXtreamId` are grouped by `(playlistId, seriesXtreamId)`, ordered by + season and episode, and represented by one poster card. Episodes without a + usable series id remain standalone cards so they never disappear. +- **Downloads workspace** (`libs/portal/downloads/feature`) + keeps orchestration in `DownloadsComponent`, async mutations in the + component-scoped `DownloadManagerActionsService`, source-route resolution in + `DownloadLibraryNavigationService`, and rendering in the presentational + `DownloadQueueComponent`, `DownloadLibraryComponent`, and + `DownloadedSeriesDialogComponent`. Presentational children emit typed + `DownloadItemAction` values and do not inject the download service, router, + dialogs, or snackbars. + The fixed header and filter row sit above one vertical scroll owner. The + queue uses compact progress rows with status text and icons; completed movies + and grouped series reuse the portal's canonical content grid. Both completed + cards and their loading skeleton consume the global + `--cover-grid-min-width` / `--cover-gap` tokens, so the Small, Medium, and + Large cover preference behaves like it does elsewhere in the app. All + surfaces use the existing `--app-*` and Material system tokens. Ready cards + do not repeat an Offline badge; source provenance is available at the top of + their overflow menus. The global + workspace download shortcut displays the service's active count, while the + page badge and filter counts reflect the current route scope. +- **Honest interactions** + Every asynchronous item command owns a pending id until the IPC result + settles, preventing duplicate dispatch without optimistically changing a + status. Service failures surface through the established snackbar path. + Removing a completed entry explicitly says the finalized media file remains + on disk. Removing a paused, failed, or canceled entry says retained partial + data is deleted. “Clear finished” communicates both outcomes and preserves + playlist scope when the page is opened under a source route. VOD and episode + detail views continue to render a paused download as an active Resume button + (`DownloadsService.isPaused()` / `resumeDownloadByContent()`). Artwork and + titles on completed movie and grouped-series cards open the focused offline + detail for that download; the explicit card Play action still starts the + local file. A legacy standalone episode without a usable series id stays + directly playable because there is no reliable series detail to build. + Missing completed rows show `File missing` under Needs attention with + `Download again`; Play and Show in folder are withheld. A file-action race + that returns `File not found` refreshes the authoritative list and returns + the user from focused details to the manager. + +## Focused offline details + +- Ready movies and grouped series open a local-focused detail view rather than + a provider catalog page. A movie exposes local Play and Show in folder. A + series projects only completed episode rows whose finalized files are still + available, grouped into seasons; every episode Play and Show in folder action + targets that row's local file. The provider's other seasons and episodes are + deliberately absent from this view. +- `View in portal` resolves an exact category/item route for Xtream. For + Stalker it accepts a matching recently-viewed snapshot only when its raw + movie/regular-series/VOD-series markers agree with the download type, so + overlapping movie and series ids cannot select the wrong item. An exact + numeric category stored in the download snapshot wins over the recent + collection's virtual `vod`/`series` category. Without a matching recent + shape, only a movie with that exact numeric category can form a + metadata-only target; episodes and legacy movies without one leave the + handoff unavailable. The normal provider detail opens in one-shot + `provider-only` presentation: provider content and playback remain available + when that host resolves them, while local, Offline, and download actions are + hidden. +- A completed row that is no longer locally available is never rendered as a + ready offline detail. Direct or stale detail URLs return to the manager; if + navigation fails, the detail shell shows the missing-file error with Back and + Retry. Invalid or removed download ids render a focused not-found state. +- Movie and episode downloads capture a versioned, display-only metadata + snapshot at start time from the already-rendered Xtream or Stalker detail, + including any TMDB fields already present. The snapshot keeps provider + identity/category separately from presentation metadata and lets the offline + view render even when the source portal is unavailable. +- A grouped series selects its newest valid parent snapshot, then fills only + missing parent metadata from older valid member snapshots. Newer values, + per-episode metadata, language, and freshness identity remain authoritative. + Each episode row still uses its own stored episode metadata. +- Legacy rows and sparse, stale, or wrong-language snapshots are backfilled + when focused details open: row metadata supplies a safe local fallback, + provider data is merged when it can be resolved, and opt-in TMDB enrichment + uses the same app language and merge rules as provider details. Successful + refreshes persist to the representative row; transient failures keep safe + improvements without falsely marking the snapshot fresh, and concurrent + refreshes are request-ordered so only the latest generation may write. +- Snapshot artwork accepts only safe HTTP(S) image URLs. Renderer normalization + and main-process persistence independently reject credential-shaped path and + query keys, including `username`, so provider credentials cannot be cached in + offline metadata. ## Global API surface @@ -42,12 +158,27 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Routing and navigation -- `/downloads` is available under both portal flavors: the Xtream routes already load `DownloadsComponent`, and the Stalker routes now import the same component so the sidebar link can target `/stalker/:id/downloads` without returning to the startup screen. +- The global workspace is `/workspace/downloads`. Source-scoped variants are + available under both portal flavors: + `/workspace/xtreams/:id/downloads` and + `/workspace/stalker/:id/downloads`. All three routes render the same global + store; the `:id` routes derive a view-only playlist scope. +- Each scope exposes the same focused child route: + `/workspace/downloads/:downloadId`, + `/workspace/xtreams/:id/downloads/:downloadId`, and + `/workspace/stalker/:id/downloads/:downloadId`. The workspace shell treats + all three as focused content: route search is disabled and the context panel + is `none`, so provider categories are not shown beside a local-only item. +- The manager persists its selected All/Movies/Series/In progress filter in + the current route query with `replaceUrl`. Opening a focused item then keeps + that exact scoped URL as its validated return target, so Back restores the + manager's scope, search query, filter, and browser-history position. - Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page. ## Queuing, persistence, and UX notes -- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations. +- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, the offline-detail metadata snapshot, and Xtream identifiers. Downloads are locally owned records rather than playlist children: deleting a source retains its rows and local files, keeps them visible in the global library, and disables provider handoff until that source exists again. Startup rebuilds older tables that still carry the playlist foreign key, while additive columns use the idempotent column migrations. +- Download-list and focused-detail reads verify completed files with asynchronous `lstat` probes in the main process. One shared probe queue permits at most four filesystem calls at once and coalesces only in-flight checks for the same path; it does not cache completed results, so external file deletion is visible on the next refresh without letting frequent progress broadcasts block Electron's main thread. - On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`. - Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation. - A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. @@ -60,6 +191,12 @@ The download manager is a desktop-only feature that layers a curated queue, prog authorized only after native folder selection, and the main process persists that selection under Electron `userData`. Renderer settings may display the path, but they are not trusted as authorization. -- The new UI leverages CSS variables for theme-specific backgrounds/borders, ensures `.downloads__list` can scroll inside its panel, and brings consistent badge/typography treatments to each card. +- The manager's search and All/Movies/Series/In progress filters affect visible + queue and library entities only. The tracked-byte summary deliberately + ignores search/category filtering while honoring route scope, so hiding a + card never makes its disk footprint appear to vanish. -Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work might cover download list filters, cancel-all actions, or integration with upcoming playback analytics. +Keeping the backend queue, IPC handlers, shared schema, and renderer signals +synchronized minimizes drift between platform rules and the UI. Future work +might cover recordings, queue reordering, bulk pause/cancel actions, disk-free +space telemetry, or playback analytics. diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 8b2d08717..dde0ed4be 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -29,6 +29,16 @@ Related: opening the series from the collection grid itself remains detail-only. If the positions load fails, the target stays unconsumed and the handoff degrades to detail-only rather than starting the episode at offset zero. +- Ready Download Manager cards open one of the three focused + `downloads/:downloadId` routes. These local details hide the workspace + context panel, play only finalized local files, and show only locally + available episodes for a series. +- `View in portal` is the explicit bridge from a focused offline detail to the + source catalog. Xtream resolves a concrete category/item route; Stalker uses + the best stored item shape or an identity/title-derived fallback. Both pass + the one-shot `detailPresentation: 'provider-only'` navigation state. The + destination keeps provider playback and whatever catalog the normal provider + host can resolve, but hides Offline/local/download presentation. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. @@ -95,6 +105,19 @@ Search behavior to preserve: - Selecting an Xtream item from search should still navigate to the canonical Xtream content type/category/item route when the item is not a live stream. +Download handoff behavior: + +- An Xtream movie handoff requires its exact VOD category and item route; a + series handoff requires its exact series category and item route. The + download snapshot's provider category is preferred, with the typed catalog + used to recover it for legacy rows. The action stays unavailable rather than + opening a bare collection route when the target cannot be resolved. +- Provider-only mode is read by the normal VOD/series detail components. It + preserves provider Play/Resume and every provider episode, while suppressing + the local Offline state and download controls. Reusing the route component + for another item must clear the mode unless that navigation explicitly + carries the marker. + ## Stalker Stalker details are represented by store state and inline detail rendering on the current screen. @@ -135,6 +158,26 @@ Behavior to preserve: - Back from the collection-owned detail should restore the previous collection tab and scope instead of resetting the collection screen to its defaults. +Download handoff behavior: + +- `View in portal` preserves Stalker's inline/store-state architecture. A + type-compatible recently-viewed snapshot is carried as `openStalkerItem` + into the normal category host, preserving regular series, embedded VOD + `series[]`, or lazy Ministra VOD `is_series=1` shape. Candidate filtering + rejects live items and the opposite movie/series namespace even when ids + overlap. When the download snapshot carries an exact numeric provider + category, that category wins over the recent record's virtual `vod` or + `series` marker while the raw mode fields stay intact. +- When no compatible recent snapshot exists, only a movie download with an + exact numeric provider category can form a metadata-only VOD target. A + legacy movie without that category and every episode without a recoverable + raw series mode leave `View in portal` unavailable instead of fabricating an + unverified provider target. +- The provider-only marker is scoped to the resulting selected item. Its normal + provider host supplies the seasons, episodes, and playback it can resolve, + while the shared VOD or series UI suppresses local Offline and download + controls. A later ordinary item open returns to normal provider presentation. + ## Decision Rule For Future Changes When deciding how a favorites/recent/search click should behave: diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 13357bc3a..d0893c2ba 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -245,11 +245,13 @@ counts only content and category deletion candidates; favorite, recently-viewed, and hidden-category user data is timed but is not added to that count. The matching write count uses the same deletion-candidate definition. Playlist-delete collection counts every collected favorite, -recently-viewed, playback-position, download, content, and category ID. Its -write count adds the final playlist row. Both write spans include every -existing cooperative checkpoint, 100-row transaction, progress callback, and, -for playlist deletion, the final playlist-row autocommit; they are not exact -SQLite commit-time measurements. +recently-viewed, playback-position, content, and category ID. Download rows are +intentionally excluded: they own local offline files independently of the +source playlist and survive source deletion, with provider handoff disabled +while that source is absent. The write count adds the final playlist row. Both +write spans include every existing cooperative checkpoint, 100-row +transaction, progress callback, and, for playlist deletion, the final +playlist-row autocommit; they are not exact SQLite commit-time measurements. Successful end markers carry only row/item counts. Error or cancellation still closes the active phase without metadata and preserves the original error. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 5b5351365..9c766986c 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -41,6 +41,8 @@ Primary route tree lives in - `/stalker/:id/search` - `/stalker/:id/actor/:personId` - `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`) +- `/stalker/:id/downloads/:downloadId` (focused local movie/series detail with + no category context panel) ## Runtime Architecture @@ -361,6 +363,23 @@ The VOD-series contract is cross-surface: - The dashboard resolves episode progress by the parent `seriesXtreamId` and renders the saved season/episode metadata. It does not infer episode numbers from provider payloads. +- Episode downloads from regular series, embedded VOD `series[]`, and lazy + Ministra VOD `is_series=1` capture the rendered parent/episode metadata and + provider category in a versioned offline snapshot. The focused Download + Manager detail uses only locally available episode rows; it does not reuse + the provider season resource as an offline availability list. +- `View in portal` first looks for a matching recently-viewed Stalker snapshot. + Candidates must match both identity and the requested movie/series mode, so + overlapping provider ids cannot bind an episode to a movie or vice versa. + When found, the handoff preserves the raw regular-series, embedded + `series[]`, or lazy `is_series=1` shape; an exact numeric category from the + download snapshot replaces a virtual `vod`/`series` collection category. +- Without that compatible snapshot, only a movie with an exact persisted + numeric category can form a metadata-only VOD target. Episodes and legacy + movies without that proof leave the provider handoff unavailable rather than + inventing a regular-series or generic VOD target. When a target does resolve, + the provider host renders its content in identity-scoped provider-only + presentation while Offline/local/download controls stay hidden. Core decision logic and normalization are centralized in: diff --git a/docs/superpowers/plans/2026-07-30-download-manager-detail-navigation.md b/docs/superpowers/plans/2026-07-30-download-manager-detail-navigation.md new file mode 100644 index 000000000..173bad334 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-download-manager-detail-navigation.md @@ -0,0 +1,462 @@ +# Download Manager Detail Navigation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make completed download cards honor the global cover-size preference, open existing detail pages from navigable card content, and prefer local playback on downloaded movie details. + +**Architecture:** Keep `DownloadLibraryComponent` presentational by emitting one generic detail intent and one explicit local-file action. Reuse the existing navigation and download action services in `DownloadsComponent`. In Xtream and shared Stalker VOD details, preserve external-player Stop/Opening precedence, then choose the completed local file before the unchanged provider playback path; provider playback remains a neutral secondary action. No metadata, schema, IPC, or offline-routing layer is added. + +**Tech Stack:** Angular 21.2 standalone components, signal inputs/outputs and computed state, Angular Material, ngx-translate, Jest 30, Nx 22.7, SCSS shared content-grid tokens, Electron Playwright E2E. + +--- + +## File map + +- `libs/portal/downloads/feature/src/lib/download-library.component.{ts,html,scss,spec.ts}` — card intent, accessibility, and global grid tokens. +- `libs/portal/downloads/feature/src/lib/downloads.component.{html,scss,spec.ts}` — smart-container wiring and skeleton grid tokens. +- `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.{ts,html}` — Xtream offline-primary/provider-secondary behavior. +- `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` — rendered Xtream download actions. +- `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` and `vod-details-route.harness.ts` — external-session and provider-playback regression coverage. +- `libs/ui/playback/src/lib/vod-details/vod-details.component.{ts,html,spec.ts}` — shared Stalker VOD action behavior and tests. +- `apps/electron-backend-e2e/src/downloads.e2e.ts` — exact movie detail navigation and Small/Medium/Large geometry. +- `docs/architecture/download-manager.md` — canonical interaction, sizing, and no-cache boundary. +- `.changes/downloads-manager-mvp.md` — user-facing corrected behavior. + +## Task 1: Fix completed-card intent and sizing with TDD + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.scss` + +- [ ] **Step 1: Write the failing interaction tests** + +Add a generic detail output and pin the movie/local split: + +```ts +it('opens movie details from artwork and title without playing locally', async () => { + const card = byTestId('download-library-movie-9'); + const opened: DownloadItem[] = []; + const actions: unknown[] = []; + component.openRequested.subscribe((selected) => opened.push(selected)); + component.itemAction.subscribe((action) => actions.push(action)); + + await click(button(card, 'Open details: Moonrise artwork')); + await click(button(card, 'Open details: Moonrise')); + + expect(opened).toEqual([MOVIE, MOVIE]); + expect(actions).toEqual([]); +}); +``` + +Update the grouped-series test to subscribe to `openRequested`. Extend the +legacy episode test to click `.download-library__artwork-button` and +`.download-library__title-button`, assert two concrete `play` actions, and +assert no detail output. Keep the existing toolbar Play assertion so it proves +that explicit Play remains local. + +- [ ] **Step 2: Write the failing style contract** + +Read `download-library.component.scss` in the spec and assert the grid block +contains both global inputs and no fixed mobile columns: + +```ts +expect(styles).toContain('var(--cover-grid-min-width, 148px)'); +expect(styles).toContain('var(--cover-gap, 16px)'); +expect(styles).not.toContain( + 'grid-template-columns: repeat(2, minmax(0, 1fr))' +); +``` + +- [ ] **Step 3: Run the focused spec and verify RED** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-library.component.spec.ts +``` + +Expected: FAIL because `openRequested` and movie detail controls do not exist, +and the SCSS still contains fixed `168px`, `clamp(...)`, and two-column values. + +- [ ] **Step 4: Implement the generic detail intent** + +Replace the series-only output with: + +```ts +readonly openRequested = output(); + +protected canOpen(item: DownloadItem): boolean { + return this.availablePlaylistIds().has(item.playlistId); +} + +protected openDetails(item: DownloadItem): void { + if (!this.isPending(item) && this.canOpen(item)) { + this.openRequested.emit(item); + } +} +``` + +Grouped-series artwork/title call +`openDetails(entity.representative)`. Movie artwork/title call +`openDetails(entity.item)` and use translated `Open details` accessible names. +The explicit movie toolbar Play continues to call +`emitAction('play', entity.item)`. Ungrouped episode artwork/title continue to +call `emitAction('play', entity.item)`. + +- [ ] **Step 5: Consume the existing cover tokens** + +Use the shared mixin without feature-owned sizing: + +```scss +.download-library__grid { + @include grid.content-grid( + $min-width: min(100%, var(--cover-grid-min-width, 148px)), + $gap: var(--cover-gap, 16px) + ); +} +``` + +Remove the `repeat(2, ...)` mobile override. Keep the existing card visuals and +overflow-safe `min(100%, ...)`. + +- [ ] **Step 6: Run the focused spec and verify GREEN** + +Run the command from Step 3. Expected: PASS. + +- [ ] **Step 7: Commit the card change** + +```bash +git add libs/portal/downloads/feature/src/lib/download-library.component.* +git commit -m "fix(downloads): open completed movies in details" +``` + +## Task 2: Wire movie navigation and synchronize the skeleton + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.scss` + +- [ ] **Step 1: Write the failing container regression** + +Render one completed movie, click its artwork, and prove navigation is distinct +from the toolbar Play: + +```ts +downloads.set([download(16, { title: 'Movie details' })]); +await fixture.whenStable(); +const card = fixture.nativeElement.querySelector( + '[data-test-id="download-library-movie-16"]' +) as HTMLElement; + +( + card.querySelector('.download-library__artwork-button') as HTMLButtonElement +).click(); +await fixture.whenStable(); +expect(navigation.open).toHaveBeenCalledWith(downloads()[0]); +expect(downloadsService.playDownload).not.toHaveBeenCalled(); + +navigation.open.mockClear(); +( + card.querySelector('.download-library__actions button') as HTMLButtonElement +).click(); +await fixture.whenStable(); +expect(downloadsService.playDownload).toHaveBeenCalledWith('/downloads/16.mp4'); +expect(navigation.open).not.toHaveBeenCalled(); +``` + +Read `downloads.component.scss` and assert `.downloads__skeleton-cards` uses +`--cover-grid-min-width` and `--cover-gap`. + +- [ ] **Step 2: Run the container spec and verify RED** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=downloads.component.spec.ts +``` + +Expected: FAIL because movie detail output is not bound and skeleton sizing is +fixed at `150px`. + +- [ ] **Step 3: Wire the output and tokens** + +Bind the presentational intent: + +```html + +``` + +Use the same token contract for the skeleton: + +```scss +.downloads__skeleton-cards { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(min(100%, var(--cover-grid-min-width, 148px)), 1fr) + ); + gap: var(--cover-gap, 16px); +} +``` + +- [ ] **Step 4: Run both Download Manager specs and verify GREEN** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-library.component.spec.ts \ + --testPathPatterns=downloads.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the integration** + +```bash +git add libs/portal/downloads/feature/src/lib/downloads.component.* +git commit -m "fix(downloads): honor the global cover size" +``` + +## Task 3: Prefer offline playback in movie details with TDD + +**Files:** + +- Create: `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html` + +- [ ] **Step 1: Write failing shared-detail tests** + +For a downloaded Stalker VOD, assert: + +```ts +expect(host.textContent).toContain('Offline'); +await click(primaryButton); +expect(playDownload).toHaveBeenCalledWith('/downloads/movie.mp4'); +expect(playClicked).not.toHaveBeenCalled(); + +await click(sourceButton); +expect(playClicked).toHaveBeenCalledWith(STALKER_VOD); +``` + +Set a matching external MPV session and assert the primary button says +`Stop MPV`, calls `closeSession`, and does not call `playDownload`. + +- [ ] **Step 2: Write failing Xtream detail tests** + +Drive the existing sparse VOD fixture with a completed path. Assert the rendered +detail includes `DOWNLOADS.OFFLINE`, primary click calls +`playDownload('/downloads/catalog-movie.mp4')` without constructing provider +playback, and the `PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE` button starts the +existing provider path. Add a playback-spec case proving a matching MPV/VLC +Stop action still outranks local playback. + +- [ ] **Step 3: Run both affected project tests and verify RED** + +Run: + +```bash +node tools/testing/run-web-esm-lib-tests.mjs \ + libs/ui/playback/src/lib/vod-details --runInBand +pnpm nx test portal-xtream-feature --runInBand \ + --testPathPatterns=vod-details-route.actions.spec.ts \ + --testPathPatterns=vod-details-route-playback.spec.ts +``` + +Expected: FAIL because the primary action still starts provider playback and +the completed button is still the local secondary action. + +- [ ] **Step 4: Implement shared Stalker precedence** + +Keep Stop first, then local, then provider: + +```ts +async onPrimaryAction(): Promise { + if (this.isExternalStopAction()) { + await this.stopExternalPlayback(); + return; + } + if (this.isDownloaded()) { + await this.playFromLocal(); + return; + } + this.onProviderAction(); +} + +onProviderAction(): void { + if (this.hasPlaybackPosition()) { + this.onResume(); + return; + } + this.onPlay(); +} +``` + +In the template, external Opening/Stop label and icon remain first. Otherwise a +downloaded item uses `DOWNLOADS.PLAY_LOCAL` with `play_circle`. Add an +`DOWNLOADS.OFFLINE` detail tag. Replace the duplicate completed local button +with a neutral `PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE` button calling +`onProviderAction()`, visible only while the external button state is idle. + +- [ ] **Step 5: Implement Xtream precedence** + +Extract today's provider/pinned/resume body into +`playFromProviderSource(vodItem)`. Keep `onPrimaryAction` as: + +```ts +async onPrimaryAction(vodItem: XtreamVodDetails | null): Promise { + if (this.playback.isExternalStopAction()) { + this.playback.onPrimaryAction(vodItem); + return; + } + if (this.isDownloaded()) { + await this.playFromLocal(); + return; + } + await this.playFromProviderSource(vodItem); +} +``` + +The secondary source button calls `playFromProviderSource(playableItem)`. +External labels/icons remain first, downloaded idle state renders +`DOWNLOADS.PLAY_LOCAL`, and both rich/fallback detail tags render +`DOWNLOADS.OFFLINE`. Hide the source button while an owned external session is +launching or running. + +- [ ] **Step 6: Run the tests and verify GREEN** + +Run both commands from Step 3. Expected: PASS. + +- [ ] **Step 7: Run the complete affected unit suites** + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test ui-playback --runInBand +pnpm nx test portal-stalker-feature --runInBand +``` + +Expected: PASS with no stale detail-action assertions. + +- [ ] **Step 8: Commit the detail behavior** + +```bash +git add libs/portal/xtream/feature/src/lib/vod-details \ + libs/ui/playback/src/lib/vod-details +git commit -m "fix(downloads): prefer offline movie playback" +``` + +## Task 4: Acceptance coverage, docs, and release note + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/downloads.e2e.ts` +- Modify: `docs/architecture/download-manager.md` +- Modify: `.changes/downloads-manager-mvp.md` + +- [ ] **Step 1: Add the failing Electron acceptance assertions** + +Seed one completed movie using a real imported VOD `xtream_id` and +`category_id`. Assert its artwork/title opens: + +```ts +`/workspace/xtreams/${playlistId}/vod/${categoryId}/${xtreamId}`; +``` + +On the Downloads page, set `document.documentElement.dataset.coverSize` to +`small`, `medium`, and `large`; record a completed card width, computed +`columnGap`, and grid column count. Assert the three settings yield the global +12/16/20px gaps and materially different card geometry. + +- [ ] **Step 2: Run targeted E2E and verify RED** + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +``` + +Expected: FAIL because movie artwork plays locally and all three cover settings +produce the same grid. + +- [ ] **Step 3: Run targeted E2E and verify GREEN after Tasks 1–3** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 4: Update canonical documentation** + +Document: + +- movie/grouped-series artwork/title to provider details; +- explicit card Play and legacy episode fallback to the local file; +- primary local and secondary provider playback on downloaded VOD details; +- external-player Stop precedence; +- global `--cover-grid-min-width` / `--cover-gap` ownership; +- provider-backed details and metadata caching as a future boundary. + +- [ ] **Step 5: Update and validate the release note** + +Keep the existing single `.changes/downloads-manager-mvp.md` note and describe +the corrected navigation, offline playback, and cover sizing in at most 400 +characters. + +```bash +pnpm run release:notes:validate +``` + +Expected: PASS. + +- [ ] **Step 6: Run repository validation** + +```bash +pnpm nx lint portal-downloads-feature +pnpm nx lint portal-xtream-feature +pnpm nx lint ui-playback +pnpm nx lint portal-stalker-feature +pnpm nx run web:typecheck +pnpm nx build web +pnpm run i18n:check +pnpm run release:notes:validate +git diff --check +``` + +Expected: every command exits zero. + +- [ ] **Step 7: Rebuild and inspect Electron** + +Rebuild the Electron/web artifacts, restart only the worktree test instance, +and inspect: + +- Small/Medium/Large at wide and narrow widths; +- movie card artwork/title versus explicit Play; +- downloaded Xtream and Stalker VOD action hierarchy; +- light and dark themes; +- absence of renderer console errors. + +- [ ] **Step 8: Commit and push** + +```bash +git add apps/electron-backend-e2e/src/downloads.e2e.ts \ + docs/architecture/download-manager.md \ + .changes/downloads-manager-mvp.md +git commit -m "test(downloads): cover detail-first offline flow" +git push origin agent/download-manager-mvp +``` diff --git a/docs/superpowers/plans/2026-07-30-download-manager-file-availability.md b/docs/superpowers/plans/2026-07-30-download-manager-file-availability.md new file mode 100644 index 000000000..23cdc8ce1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-download-manager-file-availability.md @@ -0,0 +1,908 @@ +# Download Manager File Availability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep missing finalized files out of `Ready to watch`, make them recoverable from `Needs attention`, and simplify completed cards by moving source provenance into overflow menus. + +**Architecture:** Electron derives file availability on every download read without mutating SQLite transfer status. An explicit managed-ID recovery IPC revalidates a missing completed file and requeues it at its retained destination. Angular partitions the decorated rows into active, attention, and ready entities, while shared service helpers prevent missing files from appearing as locally playable in provider details. + +**Tech Stack:** Angular 21.2 standalone components and signals, Angular Material, Electron 41 IPC/preload, Drizzle SQLite, Node filesystem APIs, Jest 30, Nx 22.7, Playwright Electron E2E, SCSS Material/system tokens. + +--- + +## File map + +- Create `apps/electron-backend/src/app/events/database/download-file-availability.ts` — trusted regular-file inspection and renderer decoration. +- Create `apps/electron-backend/src/app/events/database/download-file-availability.spec.ts` — availability classification regression tests. +- Create `apps/electron-backend/src/app/events/database/download-redownload.ts` — missing completed-file recovery by managed download ID. +- Create `apps/electron-backend/src/app/events/database/download-redownload.spec.ts` — recovery, race, retained-path, and failure tests. +- Modify `apps/electron-backend/src/app/events/database/downloads.events.ts` and its test harness/specs — decorated GET responses, secure file actions, and recovery IPC registration. +- Modify `apps/electron-backend/src/app/api/main.preload.ts` and `libs/shared/interfaces/src/lib/electron-api.interface.ts` — typed preload bridge. +- Modify `libs/services/src/lib/runtime-capabilities.service.ts` and spec — require the complete recovery-capable bridge. +- Modify `libs/services/src/lib/downloads.service.ts` and spec — availability-aware local playback and recovery method. +- Modify `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts` and spec — derived missing-file attention rows and available-only grouping. +- Create `libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts` — shared informational source header for Material menus. +- Modify queue/library components and specs — missing-file row, card cleanup, and source menus. +- Modify `download-actions.ts`, `download-manager-actions.service.ts`, and specs — `redownload` action and refresh after a late file deletion. +- Modify `download-library-navigation.service.spec.ts` — lock both Stalker series shapes to canonical details. +- Modify `apps/web/src/assets/i18n/*.json` — Download again and File missing labels. +- Modify `apps/electron-backend-e2e/src/downloads.e2e.ts` — real missing-file and recovery journey. +- Modify `docs/architecture/download-manager.md` and `.changes/downloads-manager-mvp.md` — canonical behavior and user-facing note. + +## Task 1: Derive file availability in the main process + +**Files:** + +- Create: `apps/electron-backend/src/app/events/database/download-file-availability.ts` +- Create: `apps/electron-backend/src/app/events/database/download-file-availability.spec.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads.events.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads-actions.spec.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads.test-helpers.ts` +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts` + +- [ ] **Step 1: Write failing classification and IPC tests** + +Add focused cases proving a completed regular file is available, while a +missing path, directory, symlink, or thrown `lstat` is missing: + +```ts +it.each([ + ['missing path', undefined, 'missing'], + [ + 'filesystem error', + () => { + throw new Error('ENOENT'); + }, + 'missing', + ], + [ + 'directory', + () => ({ isFile: () => false, isSymbolicLink: () => false }), + 'missing', + ], + [ + 'symlink', + () => ({ isFile: () => true, isSymbolicLink: () => true }), + 'missing', + ], +])('classifies a completed %s as %s', (_label, lstat, expected) => { + expect( + getDownloadFileAvailability(COMPLETED_ROW, lstat as DownloadLstat) + ).toBe(expected); +}); +``` + +In the downloads events specs, make `DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` +return rows with `fileAvailability`, and require Play/Reveal to reject +non-regular targets. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-file-availability.spec.ts \ + --testPathPatterns=downloads-actions.spec.ts +``` + +Expected: FAIL because the helper and decorated response do not exist and file +actions still use `existsSync`. + +- [ ] **Step 3: Add the shared availability type** + +In `electron-api.interface.ts`: + +```ts +export type ElectronDownloadFileAvailability = + 'available' | 'missing' | 'not-applicable'; + +export interface ElectronDownloadItem { + // existing fields + fileAvailability: ElectronDownloadFileAvailability; +} +``` + +- [ ] **Step 4: Implement trusted inspection and decoration** + +Create the focused helper: + +```ts +import { lstatSync, type Stats } from 'node:fs'; +import type { + ElectronBridgeDownloadStatus, + ElectronDownloadFileAvailability, +} from '@iptvnator/shared/interfaces'; + +interface DownloadAvailabilityRow { + readonly filePath?: string | null; + readonly status: ElectronBridgeDownloadStatus; +} +export type DownloadLstat = ( + path: string +) => Pick; + +export function isAvailableDownloadFile( + filePath: string | null | undefined, + lstat: DownloadLstat = lstatSync +): boolean { + if (!filePath) return false; + try { + const stat = lstat(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +export function getDownloadFileAvailability( + row: Pick, + lstat: DownloadLstat = lstatSync +): ElectronDownloadFileAvailability { + if (row.status !== 'completed') return 'not-applicable'; + return isAvailableDownloadFile(row.filePath, lstat) + ? 'available' + : 'missing'; +} + +export function decorateDownloadItem( + row: T +): T & { fileAvailability: ElectronDownloadFileAvailability } { + return { + ...row, + fileAvailability: getDownloadFileAvailability(row), + }; +} +``` + +Await the query before mapping in both GET handlers, and replace +`existsSync(filePath)` in Play/Reveal with `isAvailableDownloadFile(filePath)`. + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/electron-backend/src/app/events/database/download-file-availability.* \ + apps/electron-backend/src/app/events/database/downloads.events.ts \ + apps/electron-backend/src/app/events/database/downloads-actions.spec.ts \ + apps/electron-backend/src/app/events/database/downloads.test-helpers.ts \ + libs/shared/interfaces/src/lib/electron-api.interface.ts +git commit -m "feat(downloads): derive completed file availability" +``` + +## Task 2: Add managed missing-file recovery IPC + +**Files:** + +- Create: `apps/electron-backend/src/app/events/database/download-redownload.ts` +- Create: `apps/electron-backend/src/app/events/database/download-redownload.spec.ts` +- Modify: `apps/electron-backend/src/app/events/database/download-requests.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads.events.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads.test-helpers.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.ts` +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts` +- Modify: `libs/services/src/lib/runtime-capabilities.service.ts` +- Modify: `libs/services/src/lib/runtime-capabilities.service.spec.ts` + +- [ ] **Step 1: Write failing recovery tests** + +Cover these exact outcomes in `download-redownload.spec.ts`: + +```ts +await expect(redownloadMissingRequest(42)).resolves.toEqual({ + success: true, +}); +expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + bytesDownloaded: 0, + errorMessage: null, + resumeValidator: null, + status: 'queued', + totalBytes: null, + }) +); +expect(enqueueDownload).toHaveBeenCalledWith( + expect.objectContaining({ + directory: '/downloads', + fileName: 'movie.mp4', + filePath: '/downloads/movie.mp4', + id: 42, + }) +); +``` + +Also prove: + +- a reappeared regular file returns `{ recovered: true, success: true }` and + does not update or enqueue; +- non-completed rows are rejected; +- missing `filePath`, unavailable parent directory, unsafe remote URL, locked + partial cleanup, and a lost conditional update all fail without enqueueing. + +- [ ] **Step 2: Run the recovery spec and verify RED** + +```bash +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-redownload.spec.ts +``` + +Expected: FAIL because `redownloadMissingRequest` does not exist. + +- [ ] **Step 3: Export stored-header parsing** + +Change the existing declaration without duplicating its allowlist: + +```ts +export function parseStoredHeaders( + value: string | null +): Record | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + const entries = parsed as Record; + const headers = STORED_HEADER_ALLOWLIST.reduce>( + (acc, key) => { + const headerValue = entries[key]; + if (typeof headerValue === 'string') { + acc[key] = headerValue; + } + return acc; + }, + {} + ); + return Object.keys(headers).length > 0 ? headers : undefined; + } catch { + return undefined; + } +} +``` + +- [ ] **Step 4: Implement the explicit recovery request** + +The new module must: + +```ts +export async function redownloadMissingRequest( + downloadId: number +): Promise<{ success: boolean; recovered?: boolean; error?: string }> { + const item = await loadDownload(downloadId); + if (!item) return { error: 'Download not found', success: false }; + if (item.status !== 'completed') { + return { + error: 'Can only re-download missing completed files', + success: false, + }; + } + if (isAvailableDownloadFile(item.filePath)) { + return { recovered: true, success: true }; + } + if (!item.filePath || !isWritableDirectory(dirname(item.filePath))) { + return { error: 'Download folder is unavailable', success: false }; + } + + await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true }); + removePartialDownloadFile(item.filePath); + const claim = await updateCompletedRowToQueued(item.id); + if (hasNoChanges(claim)) { + return { error: 'Download is no longer recoverable', success: false }; + } + enqueueDownload({ + directory: dirname(item.filePath), + fileName: basename(item.filePath), + filePath: item.filePath, + headers: parseStoredHeaders(item.requestHeaders), + id: item.id, + resumeValidator: null, + totalBytes: null, + url: item.url, + }); + return { success: true }; +} +``` + +Use `lstatSync` plus `accessSync(directory, W_OK)` for the retained directory, +and a conditional `id + status='completed'` update. + +- [ ] **Step 5: Register and type the bridge** + +Add: + +```ts +export interface ElectronBridgeDownloadRedownloadResult extends ElectronBridgeErrorResult { + recovered?: boolean; +} + +// ElectronBridgeApi +downloadsRedownloadMissing: (downloadId: number) => + Promise; + +// preload +downloadsRedownloadMissing: ((downloadId: number) => + ipcRenderer.invoke('DOWNLOADS_REDOWNLOAD_MISSING', downloadId), + // main events + ipcMain.handle( + 'DOWNLOADS_REDOWNLOAD_MISSING', + async (_event, downloadId: number) => + redownloadMissingRequest(downloadId) + )); +``` + +Register the main handler, add the method to `ElectronBridgeApi`, and require it +in `RuntimeCapabilitiesService.supportsDownloads`. Update the capability spec +fixture to prove an older partial bridge returns false. + +- [ ] **Step 6: Run Electron and capability tests** + +```bash +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-redownload.spec.ts \ + --testPathPatterns=downloads-actions.spec.ts +pnpm nx test services --runInBand \ + --testPathPatterns=runtime-capabilities.service.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/electron-backend/src/app/events/database/download-redownload.* \ + apps/electron-backend/src/app/events/database/download-requests.ts \ + apps/electron-backend/src/app/events/database/downloads.events.ts \ + apps/electron-backend/src/app/events/database/downloads.test-helpers.ts \ + apps/electron-backend/src/app/api/main.preload.ts \ + libs/shared/interfaces/src/lib/electron-api.interface.ts \ + libs/services/src/lib/runtime-capabilities.service.* +git commit -m "feat(downloads): recover missing completed files" +``` + +## Task 3: Make the renderer service availability-aware + +**Files:** + +- Modify: `libs/services/src/lib/downloads.service.ts` +- Modify: `libs/services/src/lib/downloads.service.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-manager-actions.service.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-actions.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` + +- [ ] **Step 1: Write failing service and action tests** + +Add service assertions: + +```ts +expect(service.isDownloaded(id, playlistId, 'vod')).toBe(false); +expect(service.getDownloadedFilePath(id, playlistId, 'vod')).toBeUndefined(); +``` + +for a `completed` item with `fileAvailability: 'missing'`, and positive +assertions for `available`. + +Add: + +```ts +await expect(service.redownloadMissing(42)).resolves.toEqual({ + success: true, +}); +expect(window.electron.downloadsRedownloadMissing).toHaveBeenCalledWith(42); +``` + +In the action-service spec, prove `redownload` uses the managed ID and that a +Play/Reveal `{ error: 'File not found', success: false }` awaits +`loadDownloads()` before clearing pending state. + +In the Xtream and shared Stalker detail specs, supply a completed item with +`fileAvailability: 'missing'` and assert that neither the Offline tag nor the +local-primary action renders. Keep the corresponding +`fileAvailability: 'available'` assertions green. + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +pnpm nx test services --runInBand \ + --testPathPatterns=downloads.service.spec.ts +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-manager-actions.service.spec.ts +pnpm nx test portal-xtream-feature --runInBand \ + --testPathPatterns=vod-details-route.actions.spec.ts +pnpm nx test ui-playback --runInBand \ + --testPathPatterns=vod-details.component.spec.ts +``` + +Expected: FAIL because missing rows still count as downloaded and the +`redownload` action is unknown. + +- [ ] **Step 3: Implement minimal renderer behavior** + +Add the optional compatibility field to the local service model: + +```ts +fileAvailability?: ElectronDownloadFileAvailability; +``` + +Use: + +```ts +private hasAvailableCompletedFile(item: DownloadItem | undefined): boolean { + return ( + item?.status === 'completed' && + !!item.filePath && + item.fileAvailability !== 'missing' + ); +} +``` + +from both `isDownloaded()` and `getDownloadedFilePath()`. Add +`redownloadMissing(downloadId)` that calls the new bridge. + +Extend `DownloadItemActionType` with `'redownload'`, map it to the service in +`DownloadManagerActionsService`, and allow `withPending` failure callbacks to +return a promise: + +```ts +onFailure: (error?: string) => void | Promise +``` + +On `File not found`, await `downloads.loadDownloads()` and then show the +existing snackbar. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the commands from Step 2. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/services/src/lib/downloads.service.* \ + libs/portal/downloads/feature/src/lib/download-actions.ts \ + libs/portal/downloads/feature/src/lib/download-manager-actions.service.* \ + libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts \ + libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts +git commit -m "feat(downloads): refresh missing local files" +``` + +## Task 4: Partition missing completed rows from the ready library + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts` + +- [ ] **Step 1: Write failing pure view-model tests** + +Add tests proving: + +```ts +const result = build([ + download(1, { fileAvailability: 'available' }), + download(2, { fileAvailability: 'missing' }), +]); +expect(rowIds(result.attention)).toEqual([2]); +expect(result.attention[0].attentionReason).toBe('file-missing'); +expect(libraryItemIds(result.library)).toEqual([1]); +``` + +For one series with two available and one missing episode, assert the library +group contains only the two available member IDs and the missing member is one +attention row. Add fully missing series, Movies/Series filters, stable counts, +search by hidden source name, and input immutability cases. + +- [ ] **Step 2: Run the view-model spec and verify RED** + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-manager.viewmodel.spec.ts +``` + +Expected: FAIL because every completed row enters the library. + +- [ ] **Step 3: Add a presentation reason and availability predicates** + +```ts +export type DownloadAttentionReason = 'file-missing' | 'transfer'; + +export interface DownloadListItemViewModel extends DownloadLibraryRow { + readonly attentionReason: DownloadAttentionReason; + readonly seriesTitle: string; +} + +function isMissingCompletedFile(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability === 'missing'; +} + +function isReady(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability !== 'missing'; +} +``` + +Use `needsAttention(item) || isMissingCompletedFile(item)` for attention and +`isReady(item)` for library construction in both searched and count models. +Set `attentionReason` deterministically while mapping rows. + +- [ ] **Step 4: Run the view-model spec and verify GREEN** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/portal/downloads/feature/src/lib/download-manager.viewmodel.* +git commit -m "feat(downloads): separate missing files from ready media" +``` + +## Task 5: Render the missing-file queue row and reusable source header + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-queue.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-queue.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/download-queue.component.scss` +- Modify: `libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts` + +- [ ] **Step 1: Write failing queue component tests** + +Create a `completed + missing` attention row and assert: + +```ts +expect(status.textContent).toContain('File missing'); +expect(status.querySelector('mat-icon')?.textContent?.trim()).toBe('file_off'); +expect(renderedActions).toEqual(['redownload']); +expect(row.querySelector('[data-test-action="play"]')).toBeNull(); +expect(row.querySelector('[data-test-action="reveal"]')).toBeNull(); +expect(row.querySelector('.download-queue__source')).toBeNull(); +``` + +Open the Material menu and assert an informational `Source / Alpha Source` +header appears before Copy URL and Remove. Verify empty source headers are +omitted, full text is accessible, pending state disables Download again, and +the action emits `{ type: 'redownload', item }`. + +- [ ] **Step 2: Run the queue spec and verify RED** + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-queue.component.spec.ts +``` + +Expected: FAIL because the missing presentation and source header do not exist. + +- [ ] **Step 3: Implement the reusable menu header** + +Create one standalone OnPush component with signal input: + +```ts +@Component({ + selector: 'app-download-source-menu-header', + standalone: true, + imports: [MatTooltip, TranslatePipe], + template: ` + @if (sourceName().trim(); as source) { +
+ {{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }} + {{ source }} +
+ } + `, + // component-scoped token-only styles +}) +export class DownloadSourceMenuHeaderComponent { + readonly sourceName = input(''); +} +``` + +- [ ] **Step 4: Implement queue presentation** + +Add helpers: + +```ts +isMissingFile(row: DownloadListItemViewModel): boolean { + return row.attentionReason === 'file-missing'; +} + +statusKey(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) + ? 'DOWNLOADS.STATUS.FILE_MISSING' + : `DOWNLOADS.STATUS.${row.item.status.toUpperCase()}`; +} +``` + +Render `file_off`, the amber/muted modifier class, only Download again as the +primary action, and move Remove into the overflow menu for this variant. +Remove the permanent queue source span and insert the shared source header at +the top of every queue overflow menu. + +- [ ] **Step 5: Run the queue spec and verify GREEN** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts \ + libs/portal/downloads/feature/src/lib/download-queue.component.* +git commit -m "feat(downloads): surface missing files for recovery" +``` + +## Task 6: Simplify ready cards and move source into menus + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.scss` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` + +- [ ] **Step 1: Write failing library tests** + +Assert all three card shapes contain neither visible Offline nor visible source +text before a menu opens, while size remains: + +```ts +expect(section.textContent).not.toContain('Offline'); +expect(movieCard.textContent).not.toContain('Cinema'); +expect(movieCard.textContent).toContain('2.4 MB'); +``` + +Open the movie menu and assert `Source / Cinema` precedes Copy and Remove. +Open the new series menu and assert `Source / Living room` plus Open downloaded +episodes. Verify the series menu emits `episodesOpened` and no file action. + +- [ ] **Step 2: Run the library spec and verify RED** + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=download-library.component.spec.ts +``` + +Expected: FAIL because Offline and source are visible and series has no menu. + +- [ ] **Step 3: Implement the clean card contract** + +Import `DownloadSourceMenuHeaderComponent`, delete both +`.download-library__offline` spans and their SCSS, and render only tracked bytes +in `.download-library__metadata`. + +At the top of movie/episode menus add: + +```html + +``` + +Add a three-dot series action and Material menu containing the source header +and: + +```html + +``` + +Keep card sizing, Play, Reveal, detail navigation, and the existing episodes +count control unchanged. + +- [ ] **Step 4: Run the library spec and verify GREEN** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/portal/downloads/feature/src/lib/download-library.component.* +git commit -m "refactor(downloads): simplify ready cards" +``` + +## Task 7: Wire recovery and lock canonical series navigation + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-manager-actions.service.spec.ts` + +- [ ] **Step 1: Add failing integration regressions** + +Render a missing item through `DownloadsComponent`, click Download again, and +assert `downloadsRedownloadMissing(id)` receives the managed ID and no path. +Resolve the command and drive the service signal to queued, then assert the row +moves from `Needs attention` to `Downloading now`. + +Strengthen Stalker tests with: + +```ts +expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', PLAYLIST_ID, 'vod', 'vod'], + expect.anything() +); +expect(router.navigate).not.toHaveBeenCalledWith( + expect.arrayContaining(['recent']), + expect.anything() +); +``` + +Repeat for the ordinary `series/series` route. + +- [ ] **Step 2: Run the focused specs** + +```bash +pnpm nx test portal-downloads-feature --runInBand \ + --testPathPatterns=downloads.component.spec.ts \ + --testPathPatterns=download-library-navigation.service.spec.ts \ + --testPathPatterns=download-manager-actions.service.spec.ts +``` + +Expected: the container test is RED until all wiring is present; route tests +must already be GREEN on the current fixed implementation. + +- [ ] **Step 3: Update integration fixtures without changing route code** + +Keep the existing `(itemAction)="runAction($event)"` binding; it already +carries the typed action. Update completed fixture factories and the +`DownloadsService` fake explicitly: + +```ts +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + // existing required fields + fileAvailability: 'available', + status: 'completed', + ...overrides, + }; +} + +downloadsService = { + // existing methods/signals + redownloadMissing: jest.fn(success), +}; +``` + +The missing-row integration case overrides +`fileAvailability: 'missing'`. Do not change routing production code: both +exact canonical-route tests are expected to pass against the current +implementation. + +- [ ] **Step 4: Re-run and verify GREEN** + +Run the command from Step 2. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/portal/downloads/feature/src/lib/downloads.component.spec.ts \ + libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts \ + libs/portal/downloads/feature/src/lib/download-manager-actions.service.spec.ts +git commit -m "test(downloads): cover missing-file and series journeys" +``` + +## Task 8: Translate, document, and verify end to end + +**Files:** + +- Modify: `apps/web/src/assets/i18n/*.json` +- Modify: `apps/electron-backend-e2e/src/downloads.e2e.ts` +- Modify: `docs/architecture/download-manager.md` +- Modify: `.changes/downloads-manager-mvp.md` + +- [ ] **Step 1: Add translation keys** + +Add to every locale so runtime keys never leak: + +```json +"DOWNLOAD_AGAIN": "Download again", +"STATUS": { + "FILE_MISSING": "File missing" +}, +"ARIA": { + "DOWNLOAD_AGAIN": "Download {{title}} again" +} +``` + +Use Russian equivalents in `ru.json`: + +```json +"DOWNLOAD_AGAIN": "Скачать заново", +"FILE_MISSING": "Файл отсутствует", +"DOWNLOAD_AGAIN": "Скачать {{title}} заново" +``` + +Reuse `PORTALS.MULTI_SOURCE.SOURCE` for Source rather than adding a duplicate +token. + +- [ ] **Step 2: Extend the Electron E2E journey** + +After the existing real completion assertion: + +```ts +await expect(card.getByText('Offline')).toHaveCount(0); +await card.getByRole('button', { name: 'More actions: E2E Movie' }).click(); +await expect(page.getByText('Download Portal', { exact: true })).toBeVisible(); + +unlinkSync(finalPath); +await page.reload(); +await expect( + page.getByTestId(`download-queue-item-${downloadId}`) +).toContainText('File missing'); +await expect(card).toHaveCount(0); + +await page + .getByRole('button', { + name: 'Download E2E Movie again', + }) + .click(); +await expect(card).toBeVisible({ timeout: 20000 }); +expect(readFileSync(finalPath, 'utf8')).toBe('e2e download payload'); +``` + +Also assert Play/Reveal are absent while missing and the item leaves Needs +attention after recovery. + +- [ ] **Step 3: Update canonical documentation and release note** + +Document: + +- filesystem-derived readiness and no SQLite availability column; +- missing rows in Needs attention and per-episode series partitioning; +- Download again retained-path behavior; +- Source overflow placement and manager-only Offline removal; +- availability-aware detail playback. + +Rewrite the existing release-note body to remain under 400 characters while +mentioning missing-file recovery and cleaner cards. + +- [ ] **Step 4: Run complete affected validation** + +```bash +pnpm nx test electron-backend --runInBand +pnpm nx test services --runInBand +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test ui-playback --runInBand +pnpm nx lint electron-backend +pnpm nx lint services +pnpm nx lint portal-downloads-feature +pnpm nx run web:build:electron-e2e --skipNxCache --outputStyle=static +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +pnpm run release:notes:validate +``` + +Expected: all commands exit 0; the downloads E2E reports no unexpected +retries or flaky tests. + +- [ ] **Step 5: Manually inspect Electron** + +Launch with CDP, then verify in light and dark themes: + +1. Ready cards follow Small/Medium/Large and have no Offline badge. +2. Source appears only after opening `…`. +3. Missing files are muted attention rows with no Play/Reveal. +4. Download again restores the file and ready card. +5. Stalker ordinary series and VOD-series cards open details, never Recent. + +- [ ] **Step 6: Final diff checks and commit** + +```bash +pnpm exec prettier --check \ + apps/electron-backend/src/app/events/database \ + libs/services/src/lib/downloads.service.ts \ + libs/portal/downloads/feature/src/lib \ + apps/web/src/assets/i18n \ + apps/electron-backend-e2e/src/downloads.e2e.ts \ + docs/architecture/download-manager.md \ + .changes/downloads-manager-mvp.md +git diff --check +git add apps/web/src/assets/i18n \ + apps/electron-backend-e2e/src/downloads.e2e.ts \ + docs/architecture/download-manager.md \ + .changes/downloads-manager-mvp.md +git commit -m "feat(downloads): recover missing offline files" +``` diff --git a/docs/superpowers/plans/2026-07-30-download-manager-mvp.md b/docs/superpowers/plans/2026-07-30-download-manager-mvp.md new file mode 100644 index 000000000..a11d77f20 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-download-manager-mvp.md @@ -0,0 +1,1343 @@ +# Download Manager MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the desktop download manager's uniform card list with a truthful, theme-native active queue and completed-content library while preserving the existing Electron download contracts. + +**Architecture:** Keep `DownloadsService.downloads` global and derive route scope, filters, search, queue partitions, and completed-series groups in a pure view-model module. Split the Angular page into a small smart container, queue and library presentational components, a downloaded-series dialog, and an isolated navigation service; all mutations continue through the existing service and Electron broadcasts remain authoritative. + +**Tech Stack:** Angular 21.2 standalone components, signals and signal inputs/outputs, Angular Material, ngx-translate, Jest 30 with jest-preset-angular, Nx 22.7, Electron Playwright E2E, SCSS with IPTVnator theme tokens. + +--- + +## File map + +Create: + +- `libs/portal/downloads/feature/jest.config.ts` — Jest project configuration. +- `libs/portal/downloads/feature/tsconfig.spec.json` — TypeScript test configuration. +- `libs/portal/downloads/feature/src/test-setup.ts` — strict Angular zone test environment. +- `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts` — pure filtering, counting, sorting, grouping, and display metadata. +- `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts` — exhaustive pure model coverage. +- `libs/portal/downloads/feature/src/lib/download-actions.ts` — typed item/open action contracts shared by presentational children. +- `libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts` — Xtream/Stalker detail route resolution extracted from the current container. +- `libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts` — route and defensive navigation tests. +- `libs/portal/downloads/feature/src/lib/download-queue.component.ts` — active/attention queue presentation and typed outputs. +- `libs/portal/downloads/feature/src/lib/download-queue.component.html` — semantic queue regions and action controls. +- `libs/portal/downloads/feature/src/lib/download-queue.component.scss` — compact responsive queue styling. +- `libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts` — status/action/pending/accessibility tests. +- `libs/portal/downloads/feature/src/lib/download-library.component.ts` — movie, grouped-series, and fallback-episode card presentation. +- `libs/portal/downloads/feature/src/lib/download-library.component.html` — accessible poster grid and local actions. +- `libs/portal/downloads/feature/src/lib/download-library.component.scss` — `content-grid`-based responsive library styling. +- `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` — grouping-card and action tests. +- `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts` — typed downloaded-series dialog surface. +- `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html` — concrete episode rows and actions. +- `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss` — dialog layout. +- `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts` — episode identity and output tests. +- `.changes/downloads-manager-mvp.md` — user-facing release note. + +Modify: + +- `libs/services/src/lib/downloads.service.ts` and `.spec.ts` — make list loading global-only and retain latest-request protection. +- `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.{ts,spec.ts}` — expose the global active count. +- `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.{html,spec.ts}` — pass the global count to the header. +- `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.{ts,html,scss,spec.ts}` — render the numeric global badge. +- `libs/portal/downloads/feature/project.json` — add the Jest target. +- `tools/coverage/coverage-policy.json` — move the feature out of the “no test target” exclusion. +- `libs/portal/downloads/feature/src/lib/downloads.component.{ts,html,scss}` — reduce to orchestration, fixed header, state switching, and action handling. +- `apps/web/src/assets/i18n/*.json` — add the same complete download-manager key set in every locale, with authored English and Russian copy and explicit English fallback copy where a translation is unavailable. +- `apps/electron-backend-e2e/src/downloads.e2e.ts` — use semantic/test-id locators and cover the new workflow. +- `docs/architecture/download-manager.md` — document global store ownership, scoped derivation, queue/library model, grouping, and file-retention semantics. +- `CLAUDE.md` only if its current download-manager description becomes stale. + +## Task 1: Enforce the global download-store invariant + +**Files:** + +- Modify: `libs/services/src/lib/downloads.service.spec.ts` +- Modify: `libs/services/src/lib/downloads.service.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss` + +- [ ] **Step 1: Write a failing service test** + +Replace the playlist-scoped loading assertion with a no-argument contract: + +```ts +it('always loads the global download list', async () => { + const item = createDownload(1, 'playlist-1'); + const electron = { + downloadsGetList: jest.fn(async () => [item]), + }; + testWindow.electron = electron; + const service = createService(); + + await service.loadDownloads(); + + expect(electron.downloadsGetList).toHaveBeenCalledWith(); + expect(service.downloads()).toEqual([item]); +}); +``` + +Keep the overlapping-request test, but invoke `loadDownloads()` twice and +assert that a late first global response cannot replace the second response. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +pnpm nx test services --runInBand --testPathPatterns=downloads.service.spec.ts +``` + +Expected: FAIL because `downloadsGetList` currently receives one explicit +`undefined` argument. + +- [ ] **Step 3: Make list loading global-only** + +Change the public method and bridge invocation to: + +```ts +async loadDownloads(): Promise { + if (!this.isAvailable()) return; + + const requestId = ++this.loadDownloadsRequestId; + this._isLoadingDownloads.set(true); + try { + const list = await window.electron.downloadsGetList(); + if (requestId === this.loadDownloadsRequestId) { + this.downloads.set(list); + this._hasLoadedDownloads.set(true); + } + } catch (error) { + console.error('[DownloadsService] Error loading downloads:', error); + if (requestId === this.loadDownloadsRequestId) { + this._hasLoadedDownloads.set(true); + } + } finally { + if (requestId === this.loadDownloadsRequestId) { + this._isLoadingDownloads.set(false); + } + } +} +``` + +Do not change `clearCompleted(playlistId?)`; clearing remains route-scoped. + +- [ ] **Step 4: Run service tests and verify GREEN** + +Run: + +```bash +pnpm nx test services --runInBand --testPathPatterns=downloads.service.spec.ts +``` + +Expected: PASS with the global call and stale-response test both covered. + +- [ ] **Step 5: Write failing shell count tests** + +Set `DownloadsService.activeCount` to a signal with value `3` and prove: + +```ts +expect(facade.activeDownloadsCount()).toBe(3); +expect(facade.hasActiveDownloads()).toBe(true); +``` + +In the header spec, set `activeDownloadsCount` to `3` and assert the download +shortcut renders a visible `3` badge; set it to `0` and assert the badge is +absent. In the shell host spec, assert the facade count is bound through to the +header stub. + +- [ ] **Step 6: Run shell tests and verify RED** + +```bash +pnpm nx test workspace-shell-feature --runInBand \ + --testPathPatterns='workspace-shell.facade.spec.ts|workspace-shell-header.component.spec.ts|workspace-shell.component.spec.ts' +``` + +Expected: FAIL because the shell currently exposes only a boolean activity +indicator. + +- [ ] **Step 7: Render the global numeric badge** + +Add: + +```ts +readonly activeDownloadsCount = computed(() => + this.supportsDownloads ? this.downloadsService.activeCount() : 0 +); +readonly hasActiveDownloads = computed( + () => this.activeDownloadsCount() > 0 +); +``` + +Pass `[activeDownloadsCount]="facade.activeDownloadsCount()"` through the +shell, declare `readonly activeDownloadsCount = input(0)` on the header, and +render: + +```html +@if (activeDownloadsCount() > 0) { + +} +``` + +Style it with the existing selection/on-selection tokens and preserve the +current activity animation. + +- [ ] **Step 8: Run shell tests and verify GREEN** + +Run the focused shell command from Step 6. Expected: PASS. + +- [ ] **Step 9: Commit the invariant** + +```bash +git add libs/services/src/lib/downloads.service.ts \ + libs/services/src/lib/downloads.service.spec.ts \ + libs/workspace/shell/feature/src/lib/workspace-shell +git commit -m "fix(downloads): keep renderer download state global" +``` + +## Task 2: Add the feature test target and pure view model + +**Files:** + +- Create: `libs/portal/downloads/feature/jest.config.ts` +- Create: `libs/portal/downloads/feature/tsconfig.spec.json` +- Create: `libs/portal/downloads/feature/src/test-setup.ts` +- Modify: `libs/portal/downloads/feature/project.json` +- Modify: `tools/coverage/coverage-policy.json` +- Create: `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts` + +- [ ] **Step 1: Configure the test target** + +Mirror `portal-catalog-feature` with this project identity: + +```ts +// jest.config.ts +export default { + displayName: 'portal-downloads-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/downloads/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; +``` + +```ts +// src/test-setup.ts +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); +``` + +Add the same `test` executor/options as +`libs/portal/catalog/feature/project.json`, add `tsconfig.spec.json` with +`jest`/`node` types, and move `portal-downloads-feature` from `tierC` to +`tierA` with: + +```json +{ + "name": "portal-downloads-feature", + "root": "libs/portal/downloads/feature", + "sourceRoot": "libs/portal/downloads/feature/src", + "validationCommand": "pnpm nx test portal-downloads-feature", + "e2eTags": ["@downloads", "@electron", "@persistence"] +} +``` + +- [ ] **Step 2: Write failing pure-model tests** + +Use a `download(overrides)` fixture and cover these exact assertions: + +```ts +expect( + buildDownloadManagerViewModel(input).active.map((x) => x.item.id) +).toEqual([oldestId, newestId]); +expect(model.attention.map((x) => x.item.id)).toEqual([failedId, canceledId]); +expect(model.library.map((x) => x.key)).toEqual([ + 'series:playlist-a:42', + 'movie:9', +]); +expect(model.counts).toEqual({ + all: 5, + movie: 2, + series: 3, + inProgress: 2, +}); +expect(model.trackedBytes).toBe( + scopedRows.reduce((sum, row) => sum + (row.bytesDownloaded ?? 0), 0) +); +``` + +Separate tests must prove: + +- global versus `scopePlaylistId` rows; +- queued/downloading/paused versus failed/canceled versus completed; +- all/movie/series/in-progress semantics; +- counts do not change with search; +- title, derived series title, source, `SxxExx`, and error search; +- ascending queue order with `id` tie-break; +- descending library order with stable key tie-break; +- grouping by `playlistId + positive safe-integer seriesXtreamId`; +- cross-playlist provider IDs never merge; +- zero, negative, unsafe, or absent series IDs create individual episode cards; +- standardized series title extraction and legacy fallback; +- newest valid poster selection; +- season range, episode ordering, and aggregate bytes. + +- [ ] **Step 3: Run the feature test and verify RED** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand --testPathPatterns=download-manager.viewmodel.spec.ts +``` + +Expected: FAIL because `download-manager.viewmodel.ts` does not exist. + +- [ ] **Step 4: Implement the pure API** + +Export this stable surface: + +```ts +export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress'; + +export interface DownloadListItemViewModel { + readonly item: DownloadItem; + readonly episodeLabel: string; + readonly seriesTitle: string; + readonly sourceName: string; +} + +export interface DownloadMovieCardViewModel { + readonly kind: 'movie'; + readonly key: string; + readonly item: DownloadItem; + readonly newestTimestamp: number; + readonly sourceName: string; + readonly trackedBytes: number; +} + +export interface DownloadEpisodeCardViewModel { + readonly kind: 'episode'; + readonly key: string; + readonly item: DownloadItem; + readonly newestTimestamp: number; + readonly sourceName: string; + readonly trackedBytes: number; + readonly episodeLabel: string; +} + +export interface DownloadSeriesCardViewModel { + readonly kind: 'series'; + readonly key: string; + readonly representative: DownloadItem; + readonly members: readonly DownloadItem[]; + readonly seriesXtreamId: number; + readonly title: string; + readonly posterUrl?: string; + readonly newestTimestamp: number; + readonly sourceName: string; + readonly trackedBytes: number; + readonly firstSeason?: number; + readonly lastSeason?: number; +} + +export type DownloadLibraryEntity = + | DownloadMovieCardViewModel + | DownloadEpisodeCardViewModel + | DownloadSeriesCardViewModel; + +export interface DownloadManagerViewModel { + readonly scopedItems: readonly DownloadItem[]; + readonly active: readonly DownloadListItemViewModel[]; + readonly attention: readonly DownloadListItemViewModel[]; + readonly library: readonly DownloadLibraryEntity[]; + readonly counts: { + readonly all: number; + readonly movie: number; + readonly series: number; + readonly inProgress: number; + }; + readonly activeCount: number; + readonly trackedBytes: number; + readonly hasClearable: boolean; +} + +export function buildDownloadManagerViewModel(options: { + downloads: readonly DownloadItem[]; + playlists: readonly Pick[]; + scopePlaylistId?: string; + filter: DownloadFilterId; + searchTerm?: string; +}): DownloadManagerViewModel; + +export function normalizeDownloadFilter(value: string): DownloadFilterId { + return value === 'movie' || value === 'series' || value === 'in-progress' + ? value + : 'all'; +} +``` + +Normalize invalid timestamps to zero, use `Number.isSafeInteger(id) && id > 0` +for grouping, count only queued/downloading rows in `activeCount`, and use +deterministic stable keys in every final sort. + +- [ ] **Step 5: Run pure tests and verify GREEN** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand --testPathPatterns=download-manager.viewmodel.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Refactor under green** + +Keep the production model below 300 non-blank/non-comment lines by extracting +small private helpers only inside the same module. Re-run the focused test. + +- [ ] **Step 7: Commit the model** + +```bash +git add libs/portal/downloads/feature tools/coverage/coverage-policy.json +git commit -m "feat(downloads): derive queue and library view model" +``` + +## Task 3: Extract and test content navigation + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts` +- Modify later: `libs/portal/downloads/feature/src/lib/downloads.component.ts` + +- [ ] **Step 1: Write failing navigation tests** + +Test a public API shaped as: + +```ts +await navigation.open(item); +expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + item.playlistId, + 'series', + '7', + String(item.seriesXtreamId), +]); +``` + +Cover Xtream VOD, Xtream series, Stalker recent navigation state, missing +playlist, missing IDs, and missing category fallback to the collection route. +Assert `canOpen(item, availablePlaylistIds)` is false for absent source IDs. + +- [ ] **Step 2: Run and verify RED** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand --testPathPatterns=download-library-navigation.service.spec.ts +``` + +Expected: FAIL because the service is absent. + +- [ ] **Step 3: Move existing navigation logic without changing routes** + +Create an injectable class: + +```ts +@Injectable() +export class DownloadLibraryNavigationService { + private readonly router = inject(Router); + private readonly db = inject(DatabaseService); + private readonly playlists = inject(PlaylistsService); + + canOpen(item: DownloadItem, playlistIds: ReadonlySet): boolean { + return playlistIds.has(item.playlistId) && this.targetId(item) !== null; + } + + async open(item: DownloadItem): Promise { + const targetId = this.targetId(item); + if (targetId === null) return false; + + const source = await this.resolveSourceType(item.playlistId); + if (!source) return false; + + if (source === 'xtream') { + await this.openXtreamItem(item, targetId); + } else { + await this.openStalkerItem(item, targetId); + } + return true; + } + + private targetId(item: DownloadItem): number | null { + const raw = + item.contentType === 'episode' + ? (item.seriesXtreamId ?? item.xtreamId) + : item.xtreamId; + const id = Number(raw); + return Number.isFinite(id) ? id : null; + } + + private route( + source: 'xtream' | 'stalker', + playlistId: string, + segments: Array + ): Array { + return [ + '/workspace', + source === 'stalker' ? 'stalker' : 'xtreams', + playlistId, + ...segments, + ]; + } + + private async resolveSourceType( + playlistId: string + ): Promise<'xtream' | 'stalker' | null> { + try { + const playlist = await firstValueFrom( + this.playlists.getPlaylistById(playlistId) + ); + if (!playlist) return null; + return playlist.portalUrl && playlist.macAddress + ? 'stalker' + : 'xtream'; + } catch { + return null; + } + } + + private async openXtreamItem( + item: DownloadItem, + targetId: number + ): Promise { + const kind = item.contentType === 'episode' ? 'series' : 'vod'; + const content = await this.db.getContentByXtreamId( + targetId, + item.playlistId + ); + const categoryId = content?.category_id; + const segments = + categoryId === null || categoryId === undefined + ? [kind] + : [kind, String(categoryId), String(targetId)]; + await this.router.navigate( + this.route('xtream', item.playlistId, segments) + ); + } + + private normalizePortalItemId(value: unknown): string { + const raw = String(value ?? '').trim(); + return raw.includes(':') ? raw.split(':')[0] : raw; + } + + private stalkerCategory( + value: unknown, + fallback: 'vod' | 'series' + ): 'vod' | 'series' | 'itv' { + const normalized = String(value ?? '').toLowerCase(); + if (normalized === 'movie') return 'vod'; + return normalized === 'vod' || + normalized === 'series' || + normalized === 'itv' + ? normalized + : fallback; + } + + private async stalkerOpenState( + item: DownloadItem, + targetId: number, + fallback: 'vod' | 'series' + ): Promise> { + try { + const recent = (await firstValueFrom( + this.playlists.getPortalRecentlyViewed(item.playlistId) + )) as Array>; + const expected = String(targetId); + const matched = recent.find((candidate) => + ['id', 'movie_id', 'series_id', 'stream_id'].some( + (key) => + this.normalizePortalItemId(candidate[key]) === expected + ) + ); + if (matched) { + return { + ...matched, + id: + matched['id'] ?? + matched['series_id'] ?? + matched['movie_id'] ?? + expected, + category_id: this.stalkerCategory( + matched['category_id'], + fallback + ), + title: matched['title'] ?? item.title, + name: matched['name'] ?? matched['o_name'] ?? item.title, + }; + } + } catch { + // The route can still open from the persisted download metadata. + } + return { + id: String(targetId), + category_id: fallback, + title: item.title, + name: item.title, + o_name: item.title, + cover: item.posterUrl, + logo: item.posterUrl, + }; + } + + private async openStalkerItem( + item: DownloadItem, + targetId: number + ): Promise { + const fallback = item.contentType === 'episode' ? 'series' : 'vod'; + const openRecentItem = await this.stalkerOpenState( + item, + targetId, + fallback + ); + await this.router.navigate( + this.route('stalker', item.playlistId, ['recent']), + { state: { openRecentItem } } + ); + } +} +``` + +Provide it on `DownloadsComponent`; do not place it in the root injector. + +- [ ] **Step 4: Run and verify GREEN** + +Run the focused spec and then: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the extraction** + +```bash +git add libs/portal/downloads/feature/src/lib/download-library-navigation.service.* +git commit -m "refactor(downloads): isolate library navigation" +``` + +## Task 4: Build the active and attention queue with TDD + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/download-actions.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-queue.component.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-queue.component.html` +- Create: `libs/portal/downloads/feature/src/lib/download-queue.component.scss` + +- [ ] **Step 1: Define the wished-for typed event contract in the failing spec** + +```ts +export type DownloadItemActionType = + | 'cancel' + | 'copy-url' + | 'pause' + | 'play' + | 'remove' + | 'resume' + | 'retry' + | 'reveal'; + +export interface DownloadItemAction { + readonly type: DownloadItemActionType; + readonly item: DownloadItem; +} +``` + +Render one row for each status and assert: + +- queued/downloading emit pause and cancel; +- paused emits resume, cancel, and remove; +- failed/canceled emit retry and remove; +- Copy URL remains reachable from the overflow menu; +- pending IDs disable every command on that item; +- nested controls never emit `openRequested`; +- title/artwork buttons emit `openRequested`; +- progress has determinate/indeterminate mode and an accessible label; +- status text and icon both render. + +- [ ] **Step 2: Run and verify RED** + +```bash +pnpm nx test portal-downloads-feature --runInBand --testPathPatterns=download-queue.component.spec.ts +``` + +Expected: FAIL because the component is absent. + +- [ ] **Step 3: Implement signal-based inputs and outputs** + +```ts +export class DownloadQueueComponent { + readonly activeItems = + input.required(); + readonly attentionItems = + input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + emit(type: DownloadItemActionType, item: DownloadItem): void { + this.itemAction.emit({ type, item }); + } + + isPending(id: number): boolean { + return this.pendingIds().has(id); + } +} +``` + +Use real buttons, `mat-menu`, translated names, stable +`data-test-id="download-queue-item-"`, and semantic named sections for +`Downloading now` and `Needs attention`. + +- [ ] **Step 4: Run and verify GREEN** + +Run the focused spec. Expected: PASS with no unknown-element/property errors. + +- [ ] **Step 5: Commit the queue** + +```bash +git add libs/portal/downloads/feature/src/lib/download-actions.ts \ + libs/portal/downloads/feature/src/lib/download-queue.component.* +git commit -m "feat(downloads): add active download queue" +``` + +## Task 5: Build the completed library and series dialog with TDD + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-library.component.ts` +- Create: `libs/portal/downloads/feature/src/lib/download-library.component.html` +- Create: `libs/portal/downloads/feature/src/lib/download-library.component.scss` +- Create: `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts` +- Create: `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html` +- Create: `libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss` + +- [ ] **Step 1: Write failing library-card tests** + +Set `entities` with one movie, one grouped series, and one invalid-ID episode. +Assert: + +```ts +expect(movieCard.getAttribute('data-test-id')).toBe('download-library-movie-9'); +expect(seriesCard.textContent).toContain('3 episodes'); +expect(seriesCard.textContent).toContain('Seasons 1–2'); +expect(fallbackEpisodeCard.textContent).toContain('S02E04'); +``` + +Also prove: + +- movie Play/Show in folder/Copy URL/Remove emit the concrete item; +- grouped series artwork/title emits `seriesOpened` with its representative; +- `N episodes` emits `episodesOpened` with the group; +- the fallback episode has local actions but no series navigation control; +- pending item IDs disable local file actions; +- broken artwork switches to the semantic placeholder once; +- keyboard users can activate the same real buttons as pointer users. + +- [ ] **Step 2: Run library spec and verify RED** + +Run the focused library spec. Expected: FAIL because the component is absent. + +- [ ] **Step 3: Implement the library** + +Use: + +```ts +export class DownloadLibraryComponent { + readonly entities = input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly seriesOpened = output(); + readonly episodesOpened = output(); + readonly failedArtwork = signal>(new Set()); +} +``` + +In SCSS, import the canonical forwarder: + +```scss +@use '../../../../shared/ui/src/lib/styles/content-grid' as grid; + +.download-library__grid { + @include grid.content-grid( + $min-width: min(100%, 168px), + $gap: clamp(12px, 1.5vw, 20px) + ); +} +``` + +Use only existing `--app-*` and `--mat-sys-*` tokens. + +- [ ] **Step 4: Run library spec and verify GREEN** + +Expected: PASS. + +- [ ] **Step 5: Write failing downloaded-series dialog tests** + +Provide `MAT_DIALOG_DATA` with a grouped series and assert member order, +episode identity in every accessible action name, and concrete +Play/Reveal/Copy/Remove outputs. + +- [ ] **Step 6: Run dialog spec and verify RED** + +Expected: FAIL because the dialog is absent. + +- [ ] **Step 7: Implement the dialog** + +```ts +export class DownloadedSeriesDialogComponent { + readonly group = inject(MAT_DIALOG_DATA); + readonly itemAction = output(); +} +``` + +Let `MatDialogModule` own focus trap, Escape, and focus restoration. The +dialog never injects `DownloadsService`, Router, snackbars, or confirm dialogs. + +- [ ] **Step 8: Run dialog and library specs and verify GREEN** + +Expected: PASS. + +- [ ] **Step 9: Commit the ready-to-watch surface** + +```bash +git add libs/portal/downloads/feature/src/lib/download-library.component.* \ + libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.* +git commit -m "feat(downloads): add ready-to-watch library" +``` + +## Task 6: Rebuild the smart page and honest interactions + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/downloads.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.scss` +- Modify: `apps/web/src/assets/i18n/*.json` + +- [ ] **Step 1: Write failing container integration tests** + +Provide signal-backed fakes for downloads, playlists, collection context, and +route params. Assert: + +- the container calls global `loadDownloads()` with no playlist argument; +- a scoped route derives only matching rows while the root service signal + remains unchanged; +- page active count is scoped while `DownloadsService.activeCount` stays global; +- `?q=` and shared category selection feed the pure model; +- inline All/Movies/Series/In progress buttons update the shared context; +- the header's tracked byte summary ignores search/filter but honors scope; +- completed removal opens finalized-file-retained copy; +- paused/failed/canceled removal opens retained-partial-deleted copy; +- clear finished copy describes both finalized and partial outcomes; +- dialog episode actions flow back through the same smart action dispatcher; +- an item ID remains pending until its async service operation settles; +- failures use the existing translated snackbar path. + +- [ ] **Step 2: Run the container spec and verify RED** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand --testPathPatterns=downloads.component.spec.ts +``` + +Expected: FAIL against the current monolithic component. + +- [ ] **Step 3: Replace the container with computed orchestration** + +The class keeps this shape: + +```ts +@Component({ + selector: 'app-downloads', + providers: [DownloadLibraryNavigationService], + imports: [ + DownloadLibraryComponent, + DownloadQueueComponent, + EmptyStateComponent, + MatButtonModule, + MatDialogModule, + MatIcon, + MatTooltip, + TranslatePipe, + ], + templateUrl: './downloads.component.html', + styleUrl: './downloads.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadsComponent { + readonly pendingIds = signal>(new Set()); + readonly model = computed(() => + buildDownloadManagerViewModel({ + downloads: this.downloadsService.downloads(), + playlists: this.playlistItems(), + scopePlaylistId: this.playlistId() || undefined, + filter: normalizeDownloadFilter(this.selectedCategoryId()), + searchTerm: this.searchTerm(), + }) + ); +} +``` + +Delete the old card rendering and move all source route details to +`DownloadLibraryNavigationService`. Keep `DownloadsService.downloads` global; +route scope exists only in `model`. + +- [ ] **Step 4: Implement one pending action dispatcher** + +Use one `runAction({type, item})` switch. For async operations: + +```ts +private async withPending( + itemId: number, + operation: () => Promise<{ success: boolean; error?: string }> +): Promise { + if (this.pendingIds().has(itemId)) return; + this.pendingIds.update((ids) => new Set(ids).add(itemId)); + try { + const result = await operation(); + if (!result.success) this.showActionError(result.error); + } finally { + this.pendingIds.update((ids) => { + const next = new Set(ids); + next.delete(itemId); + return next; + }); + } +} +``` + +Open the status-specific confirm dialog before calling `withPending` for +Remove. Subscribe to the downloaded-series dialog component's typed output and +unsubscribe on close. + +- [ ] **Step 5: Build the fixed header and one-scroll-owner template** + +The rendered hierarchy is: + +```html +
+
+
+

{{ 'DOWNLOADS.TITLE' | translate }}

+ @if (model().activeCount > 0) { + + {{ 'DOWNLOADS.ACTIVE_COUNT' | translate: { count: + model().activeCount } }} + + } +
+
+ + {{ downloadFolder() }} + + + @if (model().hasClearable) { + + } +
+
+ {{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} · {{ + formatBytes(model().trackedBytes) }} +
+
+
+ @for (category of categories(); track category.category_id) { + + } +
+
+ + +
+
+``` + +Preserve unavailable, playlist-loading, no-playlist, no-download, and compact +filter/search-miss states. Keep folder and tracked bytes visible when there +are playlists but no downloads. Add stable test IDs to the header, filters, +queue sections, and library. + +- [ ] **Step 6: Add the complete translation key set** + +Add consistent keys under `DOWNLOADS` in every locale: + +```json +{ + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + } +} +``` + +Include episode/season pluralization keys, source/type labels, action aria +labels, and tracked-data explanation used by the templates. Author Russian +copy rather than falling back to English for `ru.json`. + +- [ ] **Step 7: Run i18n and component tests** + +Run: + +```bash +pnpm run i18n:check +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: both PASS. + +- [ ] **Step 8: Implement token-native responsive styling** + +Map all surfaces/text/borders to existing `--app-*` or `--mat-sys-*` +variables. Use one `.downloads__content { overflow-y: auto; min-height: 0; }`, +container/media queries for compact rows and stacked header, horizontal +filter-chip scrolling, visible touch actions, `:focus-visible`, and +`prefers-reduced-motion`. Add no new global design token. + +- [ ] **Step 9: Run lint, typecheck, and build** + +```bash +pnpm nx lint portal-downloads-feature +pnpm run typecheck:web +pnpm nx build web --configuration=production +``` + +Expected: PASS. If Nx's web build configuration has a different production +target name, use `pnpm nx show project web` and run its production build. + +- [ ] **Step 10: Regenerate the max-lines baseline if the old entry remains** + +```bash +node tools/eslint/generate-max-lines-baseline.mjs +git diff -- tools/eslint/max-lines-baseline.mjs +``` + +Expected: the existing `downloads.component.ts` baseline entry shrinks or is +removed; no new production file appears. + +- [ ] **Step 11: Commit the integrated page** + +```bash +git add libs/portal/downloads/feature apps/web/src/assets/i18n \ + tools/eslint/max-lines-baseline.mjs +git commit -m "feat(downloads): redesign the manager workspace" +``` + +## Task 7: Update Electron end-to-end coverage + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/downloads.e2e.ts` + +- [ ] **Step 1: Change locators first and observe the old UI fail** + +Replace CSS-class/icon-text locators with roles and stable IDs such as: + +```ts +const queueItem = page.getByTestId(`download-queue-item-${id}`); +const movieCard = page.getByTestId(`download-library-movie-${id}`); +await queueItem.getByRole('button', { name: 'Pause' }).click(); +``` + +Run the targeted atomized command: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +``` + +Expected: FAIL against the old DOM before Task 6 is applied, or fail at the +first newly asserted queue/library transition when run from the pre-redesign +commit. + +- [ ] **Step 2: Preserve and strengthen current real transfer flows** + +Keep first-run, folder selection, complete transfer, and Range pause/resume. +Update assertions so completion moves from `Downloading now` to +`Ready to watch`, Remove from manager leaves the final file on disk, and a +retained partial is deleted by removal/clearing. + +- [ ] **Step 3: Add deterministic renderer fixtures for view-only boundaries** + +Use isolated Electron data, obtain the real playlist IDs from +`window.electron.dbGetAppPlaylistMetas()`, and create download rows only +through `window.electron.downloadsStart()` against controlled local HTTP +servers. Build the following states: + +- two playlists with downloads that both appear at `/workspace/downloads`; +- each corresponding portal download route showing only its playlist; +- global workspace badge unchanged after visiting a scoped route; +- search and all four filters; +- two completed episodes from one playlist/series collapsing to one group and + opening the correct series/dialog. + +Do not use real accounts, external images, streams, or credentials. + +- [ ] **Step 4: Run targeted Electron E2E** + +Run the exact download spec target: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the E2E migration** + +```bash +git add apps/electron-backend-e2e/src/downloads.e2e.ts +git commit -m "test(downloads): cover queue and offline library flows" +``` + +## Task 8: Update canonical documentation and release note + +**Files:** + +- Modify: `docs/architecture/download-manager.md` +- Modify if stale: `CLAUDE.md` +- Create: `.changes/downloads-manager-mvp.md` + +- [ ] **Step 1: Update the canonical renderer architecture** + +Document: + +- root service list is always global; +- route scope/search/filter/grouping are pure derived state; +- active/attention/ready partitions and sorting; +- valid series identity and fallback episode behavior; +- tracked bytes are manager records, not filesystem capacity; +- finalized media remains after removal while retained partials follow the + current backend cleanup contract; +- exact new component ownership and test target. + +Remove stale claims that the page is a uniform gradient-card list. + +- [ ] **Step 2: Assess `CLAUDE.md`** + +Search its download sections. Update only claims contradicted by the new +renderer; do not duplicate the detailed architecture doc. + +- [ ] **Step 3: Assess the README screenshot** + +Check whether the root README currently presents the download manager. Refresh +an affected image only through the repository's mock-backed release capture. +If no capture action/manifest entry exists for this screen, leave published +assets unchanged and record that reason in the final task summary. + +- [ ] **Step 4: Add and validate the release note** + +Write: + +```markdown +--- +type: feature +area: downloads +--- + +Downloads now separate active transfers from a poster-based Ready to watch +library, with movie and series filters, grouped downloaded episodes, clearer +file actions, and honest removal messages about files kept on disk. +``` + +Run: + +```bash +pnpm run release:notes:validate +``` + +Expected: PASS and body length below 400 characters. + +- [ ] **Step 5: Commit docs and note** + +```bash +git add docs/architecture/download-manager.md CLAUDE.md \ + .changes/downloads-manager-mvp.md +git commit -m "docs(downloads): describe the redesigned manager" +``` + +Omit `CLAUDE.md` from `git add` if it required no change. + +## Task 9: Visual verification, local P1/P2 review gate, and PR + +**Files:** all changed files. + +- [ ] **Step 1: Run the full affected validation ladder** + +```bash +pnpm nx test services --runInBand --testPathPatterns=downloads.service.spec.ts +pnpm nx test portal-downloads-feature --runInBand +node tools/coverage/run-tier-a-coverage.mjs --projects=portal-downloads-feature +pnpm nx lint services +pnpm nx lint portal-downloads-feature +pnpm run typecheck:web +pnpm nx build web --configuration=production +pnpm run i18n:check +pnpm run release:notes:validate +``` + +Run the targeted Electron downloads E2E command from Task 7. Every command +must exit zero; warnings are investigated rather than silently accepted. +Run `pnpm run coverage:policy:check`; do not lower the merged coverage ratchet +or exempt a new runtime file to absorb missing tests. + +- [ ] **Step 2: Inspect the running UI with agent-browser** + +Launch `pnpm run serve:backend`, connect to CDP `127.0.0.1:9222`, and verify: + +- global and scoped routes; +- queue, attention, library, filters, search, and dialogs; +- wide and narrow viewport; +- light and dark themes; +- keyboard focus/activation and accessible names; +- no duplicate scroll owners, clipping, console errors, or failed resources. + +Save only local temporary screenshots; do not publish real playlist content. + +- [ ] **Step 3: Inspect the native window with Computer Use** + +Use the Computer Use skill for a final macOS window-level pass: titlebar/drag +region interaction, responsive resizing, native folder dialog return, focus +restoration, and visual polish. + +- [ ] **Step 4: Run an independent local review before any push** + +Compare the full branch diff to its merge base and dispatch independent +reviewers for: + +1. spec compliance; +2. correctness/security/data-loss risks; +3. Angular quality, accessibility, and test coverage. + +Classify findings as P0–P3. Any P0/P1/P2 finding blocks publication. + +- [ ] **Step 5: Fix every P1/P2 with regression-first TDD** + +For each valid issue, first add or adjust a test that fails for the reported +problem, run it to confirm RED, implement the fix, rerun GREEN, and ask the +reviewer to verify the exact finding. Repeat review until no open P1/P2 remains. + +- [ ] **Step 6: Re-run the full validation ladder after review fixes** + +Repeat Steps 1–3 as appropriate to the touched files. Inspect `git diff +--check`, production TS max-lines, i18n drift, release-note validation, and +working-tree status. + +- [ ] **Step 7: Decide PR shape from the final diff** + +Prefer one PR because service invariant, derived model, UI, translations, +docs, and E2E form one atomic user-visible workflow. Split only if the final +review identifies an independently shippable, independently tested change +whose temporary absence cannot break the new manager; never split tests from +the behavior they prove. + +- [ ] **Step 8: Push and create PR only after the gate is green** + +Push `agent/download-manager-mvp`, create a conventional `feat(downloads):` +PR, include validation results, local-review result, screenshots only if +mock-backed, and the release-note summary. Do not create a draft or final PR +while any P1/P2 remains. diff --git a/docs/superpowers/plans/2026-07-31-download-manager-offline-details.md b/docs/superpowers/plans/2026-07-31-download-manager-offline-details.md new file mode 100644 index 000000000..d8d0080f5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-download-manager-offline-details.md @@ -0,0 +1,1469 @@ +# Download Manager Offline Details Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add metadata-rich, full-width offline movie and series details to Download Manager, with local-only playback and an explicit handoff to the normal provider detail. + +**Architecture:** Persist a validated provider-neutral metadata snapshot on managed download rows, then render a dedicated Download Manager detail route from that local snapshot and the authoritative list of available files. A renderer metadata resolver reuses current provider records and `TmdbEnrichmentService` for best-effort enrichment/backfill. The existing provider navigation remains separate and gains a scoped provider-only presentation flag for `View in portal`. + +**Tech Stack:** Angular 20 standalone components and signals, Nx/Jest, Electron IPC, Drizzle/SQLite, Angular Material 3, ngx-translate, Playwright Electron E2E. + +--- + +## File Map + +### Shared contracts and persistence + +- Create `libs/shared/interfaces/src/lib/download-metadata.interface.ts` — bounded versioned snapshot DTO. +- Modify `libs/shared/interfaces/src/index.ts` — export the snapshot DTO. +- Modify `libs/shared/interfaces/src/lib/electron-api.interface.ts` — add snapshot fields and managed update IPC. +- Modify `libs/shared/database/src/lib/schema.ts` — add nullable `metadataSnapshot` column. +- Modify `libs/shared/database/src/lib/connection.ts` — create/migrate the SQLite column. +- Modify `libs/shared/database/src/lib/connection.spec.ts` — migration and rebuild regression coverage. + +### Electron download boundary + +- Create `apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts` — validate, bound, encode, and decode snapshots. +- Create `apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts` — malformed/oversized/credential-key cases. +- Modify `apps/electron-backend/src/app/events/database/download-requests.ts` — persist snapshot on start/restart. +- Modify `apps/electron-backend/src/app/events/database/download-file-availability.ts` — decode snapshots while decorating rows. +- Modify `apps/electron-backend/src/app/events/database/downloads.events.ts` — managed group metadata update handler. +- Modify `apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts` — row decoding coverage. +- Create `apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts` — managed update and series-group coverage. +- Modify `apps/electron-backend/src/app/api/main.preload.ts` — expose update IPC. +- Modify `apps/electron-backend/src/app/api/main.preload.spec.ts` — bridge argument coverage. + +### Renderer download model and metadata + +- Modify `libs/services/src/lib/downloads.models.ts` — expose `metadataSnapshot`. +- Modify `libs/services/src/lib/downloads.service.ts` — start/update/get helpers. +- Modify `libs/services/src/lib/downloads.service.spec.ts` — update and snapshot pass-through tests. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts` — pure movie/series offline view model. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts` — grouping, ordering, missing-file exclusion. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts` — provider/TMDB-to-snapshot mapping and safe merge. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts` — field precedence and bounds. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts` — snapshot/provider/TMDB resolution and backfill. +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts` — disabled/enabled TMDB and failure fallbacks. +- Create `libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts` — provider-neutral snapshot factories shared by download callers and offline backfill. +- Create `libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts` — snapshot factory coverage. +- Modify `libs/portal/shared/util/src/index.ts` — export snapshot factories. + +### Offline detail UI and navigation + +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts` +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html` +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss` +- Create `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts` +- Modify `libs/portal/downloads/feature/src/index.ts` — export the route component. +- Modify `libs/portal/downloads/feature/src/lib/downloads.component.ts` — card clicks enter offline detail. +- Modify `libs/portal/downloads/feature/src/lib/downloads.component.html` — remove source-availability gating from offline opens. +- Modify `libs/portal/downloads/feature/src/lib/download-library.component.ts` +- Modify `libs/portal/downloads/feature/src/lib/download-library.component.html` +- Modify `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` +- Modify `libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts` — retain only provider handoff responsibility. +- Modify `libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts` +- Modify `apps/web/src/app/app.routes.ts` — top-level focused detail route. +- Modify `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` — scoped focused detail route. +- Modify `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` — scoped focused detail route. +- Modify `libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts` — hide context/search for focused download detail. +- Modify `libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts` + +### Provider-only handoff + +- Create `libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts` — navigation-state contract. +- Modify `libs/portal/shared/util/src/index.ts` — export contract. +- Modify `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- Modify `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html` +- Modify `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- Modify `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html` +- Modify `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- Create `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts` +- Create `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- Modify `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- Modify `libs/ui/playback/src/lib/vod-details/vod-details.component.html` +- Modify `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` + +### Start-time snapshots, translations, docs, and E2E + +- Modify `libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts` +- Modify `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts` +- Create `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- Modify all `apps/web/src/assets/i18n/*.json` locale files with English fallback and localized Russian text. +- Modify `apps/electron-backend-e2e/src/downloads.e2e.ts` +- Modify `docs/architecture/download-manager.md` +- Modify `docs/architecture/portal-detail-navigation.md` +- Modify `docs/architecture/stalker-portal.md` +- Modify `CLAUDE.md` +- Modify `.changes/downloads-manager-mvp.md` + +--- + +### Task 1: Define and Persist the Metadata Snapshot Contract + +**Files:** + +- Create: `libs/shared/interfaces/src/lib/download-metadata.interface.ts` +- Modify: `libs/shared/interfaces/src/index.ts` +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts` +- Modify: `libs/shared/database/src/lib/schema.ts` +- Modify: `libs/shared/database/src/lib/connection.ts` +- Test: `libs/shared/database/src/lib/connection.spec.ts` + +- [ ] **Step 1: Write the failing database migration tests** + +Add assertions that new databases and upgraded databases expose +`metadata_snapshot`, while the pause/resume rebuild preserves the column: + +```ts +expect(downloadColumns).toContain('metadata_snapshot'); +expect(__databaseConnectionTestHooks.columnMigrationStatements).toContain( + 'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT' +); +``` + +Extend the rebuild fixture with a JSON value and assert it survives when the +legacy table already has the column. + +- [ ] **Step 2: Run the database test and verify it fails** + +Run: + +```bash +pnpm nx test database --runInBand +``` + +Expected: FAIL because `metadata_snapshot` is absent from the Drizzle schema, +create SQL, and migration statements. + +- [ ] **Step 3: Add the snapshot DTO and bridge fields** + +Create the complete provider-neutral contract: + +```ts +export interface DownloadMetadataPerson { + tmdbPersonId?: number; + name: string; + role?: string; + profileUrl?: string; +} + +export interface DownloadEpisodeMetadata { + title?: string; + plot?: string; + stillUrl?: string; + seasonNumber: number; + episodeNumber: number; +} + +export interface DownloadMetadataSnapshot { + version: 1; + language: string; + mediaKind: 'movie' | 'series'; + title: string; + originalTitle?: string; + plot?: string; + releaseDate?: string; + year?: number; + durationMinutes?: number; + genres?: string[]; + rating?: number; + status?: string; + posterUrl?: string; + backdropUrl?: string; + tmdbId?: number; + providerCategoryId?: string; + cast?: DownloadMetadataPerson[]; + creators?: DownloadMetadataPerson[]; + episode?: DownloadEpisodeMetadata; + enrichedAt?: string; +} +``` + +Export it and add `metadataSnapshot?: DownloadMetadataSnapshot` to +`ElectronBridgeDownloadStartPayload` and `ElectronDownloadItem`. Add this API +method: + +```ts +downloadsUpdateMetadata: ( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot +) => Promise; +``` + +- [ ] **Step 4: Add the nullable SQLite column** + +Add `metadataSnapshot: text('metadata_snapshot')` to the Drizzle table, add the +column to `DOWNLOADS_TABLE_SQL`, and append: + +```ts +`ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`; +``` + +to `COLUMN_MIGRATION_STATEMENTS`. Preserve `metadata_snapshot` in the +pause/resume rebuild by detecting the column and selecting either +`metadata_snapshot` or `NULL AS metadata_snapshot`. + +- [ ] **Step 5: Run the database and interface checks** + +Run: + +```bash +pnpm nx test database --runInBand +pnpm nx lint database +pnpm nx test shared-interfaces --runInBand +``` + +Expected: all targets pass and no new lint errors are reported. + +- [ ] **Step 6: Commit the contract** + +```bash +git add libs/shared/interfaces libs/shared/database +git commit -m "feat(downloads): persist offline metadata snapshots" +``` + +--- + +### Task 2: Validate Snapshots and Add Managed Metadata IPC + +**Files:** + +- Create: `apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts` +- Create: `apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts` +- Create: `apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts` +- Modify: `apps/electron-backend/src/app/events/database/download-requests.ts` +- Modify: `apps/electron-backend/src/app/events/database/download-file-availability.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads.events.ts` +- Modify: `apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.spec.ts` + +- [ ] **Step 1: Write failing validation tests** + +Cover a valid snapshot, invalid version, empty title, more than 30 cast members, +oversized JSON, and forbidden nested keys such as `url`, `headers`, `password`, +`macAddress`, and `cookie`: + +```ts +expect(encodeDownloadMetadataSnapshot(validSnapshot)).toBe( + JSON.stringify(validSnapshot) +); +expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + password: 'secret', + } as never) +).toThrow('Invalid download metadata snapshot'); +``` + +Use a fixed UTF-8 ceiling of `128 * 1024` bytes. + +- [ ] **Step 2: Write failing IPC tests** + +Register the downloads event module with the existing database test harness and +assert: + +```ts +await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, row.id, snapshot) +).resolves.toEqual({ success: true }); +expect(updateWhere).toHaveBeenCalled(); +``` + +For an episode, assert the update predicate is derived from the stored +`playlistId`, `seriesXtreamId`, and `contentType`, updating the whole managed +series group. Give the group distinct stored episode snapshots and assert the +parent fields update while every row keeps its own episode title/coordinate. +Assert a movie updates only its managed id. Assert a missing id, invalid +snapshot, or unsafe payload returns `{ success: false }`. + +- [ ] **Step 3: Run Electron backend tests and verify failure** + +Run: + +```bash +pnpm nx test electron-backend --runInBand +``` + +Expected: FAIL because the validator and `DOWNLOADS_UPDATE_METADATA` do not +exist. + +- [ ] **Step 4: Implement the bounded validator** + +Implement an allowlist-based clone rather than persisting arbitrary input: + +```ts +export const DOWNLOAD_METADATA_MAX_BYTES = 128 * 1024; + +export function encodeDownloadMetadataSnapshot( + input: DownloadMetadataSnapshot +): string { + const snapshot = normalizeDownloadMetadataSnapshot(input); + const encoded = JSON.stringify(snapshot); + if (Buffer.byteLength(encoded, 'utf8') > DOWNLOAD_METADATA_MAX_BYTES) { + throw new Error('Download metadata snapshot is too large'); + } + return encoded; +} + +export function decodeDownloadMetadataSnapshot( + value: string | null | undefined +): DownloadMetadataSnapshot | undefined { + if (!value) return undefined; + try { + return normalizeDownloadMetadataSnapshot(JSON.parse(value)); + } catch { + return undefined; + } +} +``` + +The normalizer keeps only defined DTO fields, trims strings, caps people arrays +at 30 and genre arrays at 20, and rejects unknown credential-bearing keys +before cloning. + +- [ ] **Step 5: Persist and decode snapshots** + +In `startDownloadRequest`, encode the optional start payload and write it on +insert. On restart, replace the stored snapshot only when a new valid snapshot +is supplied. + +Decode the Drizzle string in `decorateDownloadItem`: + +```ts +return { + ...row, + metadataSnapshot: decodeDownloadMetadataSnapshot(row.metadataSnapshot), + fileAvailability: getDownloadFileAvailability(row, lstat), +}; +``` + +Remove the raw JSON string from the object returned to the renderer. + +- [ ] **Step 6: Implement managed group update** + +Register: + +```ts +ipcMain.handle( + 'DOWNLOADS_UPDATE_METADATA', + async (_event, downloadId: number, snapshot: DownloadMetadataSnapshot) => { + const encoded = encodeDownloadMetadataSnapshot(snapshot); + const [row] = await db + .select() + .from(schema.downloads) + .where(eq(schema.downloads.id, downloadId)) + .limit(1); + if (!row) return { error: 'Download not found', success: false }; + + if (row.contentType !== 'episode' || !row.seriesXtreamId) { + await db + .update(schema.downloads) + .set({ + metadataSnapshot: encoded, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, downloadId)); + return { success: true }; + } + + const members = await db + .select() + .from(schema.downloads) + .where( + and( + eq(schema.downloads.playlistId, row.playlistId), + eq(schema.downloads.contentType, 'episode'), + eq(schema.downloads.seriesXtreamId, row.seriesXtreamId) + ) + ); + for (const member of members) { + const current = decodeDownloadMetadataSnapshot( + member.metadataSnapshot + ); + const memberSnapshot = { + ...snapshot, + episode: + current?.episode ?? + (member.seasonNumber && member.episodeNumber + ? { + episodeNumber: member.episodeNumber, + seasonNumber: member.seasonNumber, + title: member.title, + } + : undefined), + }; + await db + .update(schema.downloads) + .set({ + metadataSnapshot: + encodeDownloadMetadataSnapshot(memberSnapshot), + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.downloads.id, member.id)); + } + return { success: true }; + } +); +``` + +Use the database transaction API around the series member updates so the group +cannot be half-refreshed. Expose the exact two-argument bridge method from the +preload. + +- [ ] **Step 7: Run backend tests** + +Run: + +```bash +pnpm nx test electron-backend --runInBand +pnpm nx lint electron-backend +``` + +Expected: all tests pass; lint reports zero new errors. + +- [ ] **Step 8: Commit the IPC boundary** + +```bash +git add apps/electron-backend +git commit -m "feat(downloads): manage offline metadata snapshots" +``` + +--- + +### Task 3: Expose Snapshot APIs in DownloadsService + +**Files:** + +- Modify: `libs/services/src/lib/downloads.models.ts` +- Modify: `libs/services/src/lib/downloads.service.ts` +- Test: `libs/services/src/lib/downloads.service.spec.ts` + +- [ ] **Step 1: Write failing service tests** + +Assert start forwards `metadataSnapshot`, update sends only managed id plus +snapshot, and `getDownload` returns the current signal row: + +```ts +await service.updateMetadata(42, snapshot); +expect(electron.downloadsUpdateMetadata).toHaveBeenCalledWith(42, snapshot); +expect(service.getDownload(42)).toEqual(expect.objectContaining({ id: 42 })); +``` + +Also assert an unsupported runtime returns `{ success: false }` without bridge +access. + +- [ ] **Step 2: Run the service test and verify it fails** + +Run: + +```bash +pnpm nx test services --runInBand +``` + +Expected: FAIL because `DownloadItem.metadataSnapshot`, `getDownload`, and +`updateMetadata` are absent. + +- [ ] **Step 3: Add the renderer model and methods** + +Add the imported snapshot type to `DownloadItem`, extend the `startDownload` +input, and implement: + +```ts +getDownload(downloadId: number): DownloadItem | undefined { + return this.downloads().find(({ id }) => id === downloadId); +} + +async updateMetadata( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot +): Promise<{ success: boolean; error?: string }> { + if (!this.isAvailable()) return { success: false }; + const result = await window.electron.downloadsUpdateMetadata( + downloadId, + metadataSnapshot + ); + if (result.success) await this.loadDownloads(); + return result; +} +``` + +- [ ] **Step 4: Run service tests and lint** + +Run: + +```bash +pnpm nx test services --runInBand +pnpm nx lint services +``` + +Expected: tests pass and there are no new lint errors. + +- [ ] **Step 5: Commit the renderer service API** + +```bash +git add libs/services +git commit -m "feat(downloads): expose offline metadata updates" +``` + +--- + +### Task 4: Build the Pure Offline Detail View Model + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts` + +- [ ] **Step 1: Write failing movie and series view-model tests** + +Use frozen inputs. Prove that a movie resolves by id, while a representative +episode resolves all available group members and excludes missing/non-completed +rows: + +```ts +const result = buildDownloadOfflineDetail({ + downloadId: 10, + downloads: [ + episode(10, 1, 5, 'available'), + episode(11, 1, 2, 'available'), + episode(12, 1, 3, 'missing'), + episode(13, 2, 1, 'available'), + ], +}); + +expect(result?.kind).toBe('series'); +expect(result?.seasons.map(({ seasonNumber }) => seasonNumber)).toEqual([1, 2]); +expect( + result?.seasons[0].episodes.map(({ episodeNumber }) => episodeNumber) +).toEqual([2, 5]); +``` + +Assert invalid ids, missing representatives, and mixed playlists/series ids do +not leak into a grouped series. A legacy episode without `seriesXtreamId` +resolves as an isolated one-episode series detail so its local file remains +reachable, but it never groups with unrelated legacy rows. + +- [ ] **Step 2: Run the focused project test and verify it fails** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: FAIL because the view-model module does not exist. + +- [ ] **Step 3: Implement immutable filtering and ordering** + +Export discriminated view models: + +```ts +export type DownloadOfflineDetail = + | { kind: 'movie'; item: DownloadItem; snapshot?: DownloadMetadataSnapshot } + | { + kind: 'series'; + representative: DownloadItem; + snapshot?: DownloadMetadataSnapshot; + seasons: DownloadOfflineSeason[]; + }; +``` + +Define availability as `status === 'completed'`, non-empty `filePath`, and +`fileAvailability !== 'missing'`. Sort seasons and episodes numerically, then +by `createdAt`, then `id`. Select the newest valid parent snapshot while +retaining the representative's episode metadata per row. For an ungrouped +legacy episode, synthesize exactly one season/episode entry from its stored +coordinates and title rather than returning not found. + +- [ ] **Step 4: Run the view-model tests** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: all project tests pass. + +- [ ] **Step 5: Commit the offline projection** + +```bash +git add libs/portal/downloads/feature/src/lib/offline-detail +git commit -m "feat(downloads): derive offline detail content" +``` + +--- + +### Task 5: Add Focused Download Detail Routes and Shell State + +**Files:** + +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts` +- Modify: `apps/web/src/app/app.routes.ts` +- Modify: `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` +- Modify: `libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts` +- Test: `libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts` +- Modify: `libs/portal/downloads/feature/src/index.ts` + +- [ ] **Step 1: Write failing route-state tests** + +Add: + +```ts +expect( + parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads/42') +).toEqual( + expect.objectContaining({ + section: 'downloads', + contextPanel: 'none', + searchMode: 'none', + usesQuerySearch: false, + }) +); + +expect(parseWorkspaceShellRoute('/workspace/downloads/42')).toEqual( + expect.objectContaining({ + kind: 'downloads', + contextPanel: 'none', + searchMode: 'none', + }) +); +``` + +Keep existing `/downloads` assertions unchanged. + +- [ ] **Step 2: Run route-state tests and verify failure** + +Run: + +```bash +pnpm nx test workspace-shell-util --runInBand +``` + +Expected: FAIL because focused detail still inherits downloads search/collection +state. + +- [ ] **Step 3: Parse focused details explicitly** + +Compute: + +```ts +const isFocusedDownload = + (provider !== null && + sectionSegment === 'downloads' && + segments.length >= 5) || + (provider === null && segments[1] === 'downloads' && segments.length >= 3); +``` + +Override `searchMode`, `usesQuerySearch`, and `contextPanel` to `none` only for +that state. + +- [ ] **Step 4: Register all three detail routes** + +Create and export a route-loadable shell so route compilation remains green +before the complete presentation lands: + +```ts +@Component({ + selector: 'app-download-offline-detail', + imports: [PortalDetailShellComponent], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadOfflineDetailComponent {} +``` + +Add `downloads/:downloadId` before or beside each existing exact downloads +route: + +```ts +{ + path: 'downloads/:downloadId', + loadComponent: () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadOfflineDetailComponent + ), +}, +``` + +- [ ] **Step 5: Run shell tests and an Angular build** + +Run: + +```bash +pnpm nx test workspace-shell-util --runInBand +pnpm nx run web:build:electron-e2e --skipNxCache --outputStyle=static +``` + +Expected: tests and build pass. + +- [ ] **Step 6: Commit focused routing** + +```bash +git add apps/web/src/app/app.routes.ts libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts libs/workspace/shell/util libs/portal/downloads/feature/src/index.ts libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts +git commit -m "feat(downloads): add focused offline detail routes" +``` + +--- + +### Task 6: Make Ready Cards Open Offline Detail + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/downloads.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.html` +- Modify: `libs/portal/downloads/feature/src/lib/download-library.component.spec.ts` + +- [ ] **Step 1: Write failing card-navigation tests** + +Assert movie and series card opens call: + +```ts +expect(router.navigate).toHaveBeenCalledWith(['17'], { + relativeTo: route, + state: { returnUrl: '/workspace/downloads?q=signal' }, +}); +``` + +Assert cards remain openable when the source playlist is unavailable because +local details do not depend on the provider. Keep explicit local Play +unchanged. + +- [ ] **Step 2: Run the downloads feature tests and verify failure** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: FAIL because card opens still invoke provider navigation and the +library disables cards without a source playlist. + +- [ ] **Step 3: Replace card-open provider navigation** + +Implement: + +```ts +openOfflineDetail(item: DownloadItem): void { + if (this.pendingIds().has(item.id)) return; + void this.router.navigate([String(item.id)], { + relativeTo: this.route, + state: { returnUrl: this.router.url }, + }); +} +``` + +Wire `openRequested` to this method. Remove `availablePlaylistIds` from +`DownloadLibraryComponent` only where it controlled detail-card disabled state; +source availability remains a concern of `View in portal`. + +- [ ] **Step 4: Run feature tests** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx lint portal-downloads-feature +``` + +Expected: all tests pass and lint has no errors. + +- [ ] **Step 5: Commit card navigation** + +```bash +git add libs/portal/downloads/feature +git commit -m "feat(downloads): open ready cards in offline details" +``` + +--- + +### Task 7: Resolve Provider and TMDB Metadata + +**Files:** + +- Create: `libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts` +- Create: `libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts` +- Modify: `libs/portal/shared/util/src/index.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts` + +- [ ] **Step 1: Write failing mapper tests** + +Prove provider values seed the snapshot and TMDB replaces only editorial +fields: + +```ts +const merged = mergeSnapshotWithTmdb(providerSnapshot, tmdbDetails); +expect(merged.plot).toBe('Localized TMDB overview'); +expect(merged.posterUrl).toContain('image.tmdb.org'); +expect(merged.providerCategoryId).toBe('12'); +expect(merged.title).toBe('Provider title'); +expect(merged.cast).toHaveLength(2); +``` + +Assert people and genre arrays are bounded and credentials/stream URLs are not +part of the returned DTO. + +- [ ] **Step 2: Write failing resolver tests** + +Cover: + +1. a rich, current-language snapshot renders immediately and avoids provider + lookup; +2. a sparse Stalker legacy row uses the matching portal recent item; +3. a sparse Xtream row uses `DatabaseService.getContentByXtreamId`; +4. enabled TMDB calls `enrichMovie` or `enrichTv`; +5. disabled/failing TMDB retains provider/local data; +6. a successful merge calls `DownloadsService.updateMetadata` once; +7. series enrichment never adds episodes. + +- [ ] **Step 3: Run downloads feature tests and verify failure** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: FAIL because mapper and resolver do not exist. + +- [ ] **Step 4: Implement deterministic snapshot mapping** + +Create provider-neutral factories in `@iptvnator/portal/shared/util`: + +```ts +export function createMovieDownloadSnapshot( + input: DownloadMovieSnapshotInput +): DownloadMetadataSnapshot { + return { + version: 1, + language: input.language, + mediaKind: 'movie', + title: input.title, + plot: input.plot, + posterUrl: input.posterUrl, + enrichedAt: new Date().toISOString(), + }; +} +``` + +Add a matching `createSeriesEpisodeDownloadSnapshot` factory accepting parent +series and episode display fields. The offline mapper supplies provider-record +fields from Xtream content and Stalker recent payloads. Reuse +`mergeVodInfoWithTmdb`, +`mergeSerieInfoWithTmdb`, and `mergeStalkerInfoWithTmdb` through normalized +provider seeds rather than inventing different precedence. + +- [ ] **Step 5: Implement asynchronous best-effort resolution** + +The service exposes: + +```ts +async resolve( + detail: DownloadOfflineDetail +): Promise +``` + +It starts from the best local snapshot, resolves playlist type, loads a matching +provider record only when the snapshot is sparse/stale/language-mismatched, +applies TMDB when enabled, persists a changed snapshot, and returns the best +result even when every network/provider step fails. + +- [ ] **Step 6: Run feature and service tests** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-shared-util --runInBand +pnpm nx test services --runInBand +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit metadata resolution** + +```bash +git add libs/portal/shared/util libs/portal/downloads/feature/src/lib/offline-detail +git commit -m "feat(downloads): enrich offline detail metadata" +``` + +--- + +### Task 8: Implement the Offline Movie and Series Detail UI + +**Files:** + +- Modify: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss` +- Create: `libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts` +- Modify: `libs/portal/downloads/feature/src/index.ts` + +- [ ] **Step 1: Write failing component tests** + +Use signal-backed `DownloadsService` stubs and route param `downloadId=17`. +Assert: + +```ts +expect(text()).toContain('Available offline'); +expect(button('Play offline')).toBeTruthy(); +expect(text()).not.toContain('Play from source'); +expect(button('View in portal')).toBeTruthy(); +``` + +For series, assert only available rows render, season chips expose counts, and +each row emits its own local Play. Assert loading, missing row, source-missing, +and file-race states. Also assert the hero exposes the available year, genres, +duration, rating, status, cast, and creators while gracefully omitting absent +fields. + +- [ ] **Step 2: Run feature tests and verify failure** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +``` + +Expected: FAIL because the route-loadable shell does not implement the offline +detail behavior yet. + +- [ ] **Step 3: Implement signal-owned route state** + +The component: + +- loads downloads on entry; +- derives `downloadId` from `ActivatedRoute.paramMap`; +- computes the pure offline detail; +- resolves enriched metadata without blocking the local view; +- uses `DownloadManagerActionsService` for Play/Reveal race handling; +- provides both `DownloadManagerActionsService` and + `DownloadLibraryNavigationService` at the route-component scope; +- calls `Location.back()` when navigation history exists, otherwise navigates + to the derived global/scoped downloads fallback. + +Keep the TypeScript production file below the repository max-lines threshold by +placing metadata mapping and view-model logic in the files created earlier. + +- [ ] **Step 4: Build the shared-detail visual structure** + +Use `PortalDetailShellComponent`: + +```html + + + + {{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }} + + + + + @if (detail().kind === 'movie') { + + } + + + +``` + +Project season chips and offline episode rows into `[detail-episodes]`. +Add an `appDetailMeta` template for bounded `metadata.cast` and +`metadata.creators`, using the same avatar/name chip anatomy as the Xtream and +Stalker detail views. These offline chips are informational rather than +provider-navigation links; the explicit `View in portal` action owns the +provider transition. Put year, duration, genres, rating, and status into the +hero tag/meta slots with `@if` guards so sparse legacy snapshots never render +placeholder values. Show file size on the movie facts row and on each episode +row. Use only current `--app-*` and Material system tokens. + +For `View in portal`, keep the disabled action visible and attach the localized +unavailable explanation as its tooltip and accessible description whenever +the source playlist or target cannot be resolved. + +- [ ] **Step 5: Handle disappearing files honestly** + +After a `File not found` action result, reload downloads and replace-navigate +back to the manager route. If the initial detail resolves to missing/not found, +render the focused error state with Back rather than provider fallback. + +- [ ] **Step 6: Run component tests, lint, and build** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx lint portal-downloads-feature +pnpm nx run web:build:electron-e2e --skipNxCache --outputStyle=static +``` + +Expected: tests, lint, and build pass. + +- [ ] **Step 7: Commit the offline UI** + +```bash +git add libs/portal/downloads/feature +git commit -m "feat(downloads): render offline movie and series details" +``` + +--- + +### Task 9: Add the Explicit Provider-only Handoff + +**Files:** + +- Create: `libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts` +- Modify: `libs/portal/shared/util/src/index.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts` +- Modify: `libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- Create: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts` +- Create: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.html` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` + +- [ ] **Step 1: Write failing navigation tests** + +Assert Xtream navigation adds `state: { detailPresentation: 'provider-only' }` +and Stalker preserves its existing item state plus the same flag. Assert all +regular callers remain unchanged. Resolve a concrete provider target before +enabling the action: Xtream uses the snapshot `providerCategoryId` first and +falls back to `DatabaseService.getContentByXtreamId`; it must return unavailable +instead of navigating to a bare VOD/series collection when neither can identify +the item route. + +- [ ] **Step 2: Write failing provider presentation tests** + +For a downloaded Xtream movie opened with provider-only state, assert: + +```ts +expect(component.providerOnly()).toBe(true); +expect(text()).not.toContain('Offline'); +expect(text()).not.toContain('Play from this source'); +expect(providerPlayButton()).toBeTruthy(); +``` + +For series/Stalker, assert full provider episodes remain present and local +download actions/badges are absent. Cover both Stalker hosts +(`StalkerCatalogDetailComponent` and `StalkerInlineDetailComponent`) and assert +they pass the provider-only mode to regular VOD, Ministra series, and VOD-series +children. + +- [ ] **Step 3: Run affected portal tests and verify failure** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +``` + +Expected: FAIL because the presentation state is not consumed. + +- [ ] **Step 4: Define and carry the scoped state** + +Create: + +```ts +export const PROVIDER_ONLY_DETAIL_PRESENTATION = 'provider-only' as const; + +export function isProviderOnlyDetailState(state: unknown): boolean { + return ( + typeof state === 'object' && + state !== null && + (state as { detailPresentation?: unknown }).detailPresentation === + PROVIDER_ONLY_DETAIL_PRESENTATION + ); +} +``` + +The download navigation service adds this field without removing the Stalker +selected-item payload. Refactor it around one async +`resolveProviderTarget(item)` result used by both availability and navigation, +so the visible enabled state cannot disagree with the eventual destination. +Cache that result in the offline route component for the current detail id and +invalidate it when the route item changes. + +- [ ] **Step 5: Suppress offline behavior only for this handoff** + +Read `history.state` once per detail host into a signal. In provider-only mode: + +- Xtream movie `isDownloaded` presentation becomes false while provider + playback continues through its existing source resolver; +- Xtream series renders its complete provider season/episode list; +- Stalker movie/series keeps the normal provider store and playback controller; +- shared `VodDetailsComponent` and `StalkerSeriesViewComponent` receive a + `providerOnly` input from the Stalker detail hosts and hide Offline, + Play-local, Resume-download, and Download actions while retaining provider + Play/Resume; +- no component mutates the global DownloadsService state; +- a normal category/favorite/recent open continues to use existing behavior. + +- [ ] **Step 6: Run all portal tests** + +Run: + +```bash +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-shared-util --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +pnpm nx test portal-stalker-data-access --runInBand +pnpm nx test ui-playback --runInBand +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit the provider handoff** + +```bash +git add libs/portal/shared/util libs/portal/downloads/feature libs/portal/xtream/feature libs/portal/stalker/feature libs/ui/playback +git commit -m "feat(downloads): hand off to provider-only details" +``` + +--- + +### Task 10: Capture Rich Snapshots When Downloads Start + +**Files:** + +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts` +- Create: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` +- Reuse: `libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts` + +- [ ] **Step 1: Verify the planned shared-util dependency** + +Run: + +```bash +pnpm nx graph --file=/tmp/iptvnator-offline-detail-graph.json +``` + +Confirm Xtream/Stalker features import the snapshot factories only from +`@iptvnator/portal/shared/util`; they must not import +`portal-downloads-feature`. + +- [ ] **Step 2: Write failing Xtream start-payload assertions** + +Extend the current movie download spec: + +```ts +expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + metadataSnapshot: expect.objectContaining({ + version: 1, + mediaKind: 'movie', + title: 'Metadata Movie', + plot: 'Provider or TMDB description', + }), + }) +); +``` + +- [ ] **Step 3: Write failing Stalker episode snapshot assertions** + +Add a focused `stalker-vod-download.spec.ts` assertion for movie metadata, then +assert `downloadEpisode` includes parent series and episode metadata: + +```ts +expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + metadataSnapshot: expect.objectContaining({ + mediaKind: 'series', + title: 'Signal House', + episode: { + seasonNumber: 1, + episodeNumber: 2, + title: 'The Call', + }, + }), + }) +); +``` + +- [ ] **Step 4: Run affected tests and verify failure** + +Run: + +```bash +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +``` + +Expected: FAIL because Xtream movie, Stalker movie, and Stalker episode start +payloads lack `metadataSnapshot`. + +- [ ] **Step 5: Map already-enriched detail state into snapshots** + +Xtream uses the currently rendered `VodInfo` after the existing TMDB store +merge. Stalker movie maps `VodDetailsItem.data.info`. Stalker series uses +`displayItem().info` plus `getEpisodeInfo(episode)`. All three call the shared +provider-neutral factories. Do not trigger extra TMDB requests at +download-click time; capture the best state already available and let the +offline resolver backfill later. + +- [ ] **Step 6: Run provider tests and boundary lint** + +Run: + +```bash +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +pnpm nx lint portal-xtream-feature +pnpm nx lint portal-stalker-feature +``` + +Expected: tests and lint pass without module-boundary violations. + +- [ ] **Step 7: Commit start-time metadata** + +```bash +git add libs/portal/shared/util libs/portal/xtream/feature libs/portal/stalker/feature +git commit -m "feat(downloads): capture metadata at download time" +``` + +--- + +### Task 11: Add Localizations, Canonical Docs, and Release Note + +**Files:** + +- Modify: all `apps/web/src/assets/i18n/*.json` +- Modify: `docs/architecture/download-manager.md` +- Modify: `docs/architecture/portal-detail-navigation.md` +- Modify: `docs/architecture/stalker-portal.md` +- Modify: `CLAUDE.md` +- Modify: `.changes/downloads-manager-mvp.md` + +- [ ] **Step 1: Add translation keys** + +Add under `DOWNLOADS.OFFLINE_DETAIL`: + +```json +{ + "AVAILABLE": "Available offline", + "PLAY": "Play offline", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_UNAVAILABLE": "The source item is no longer available", + "DOWNLOADED_EPISODES": "{{count}} downloaded episodes", + "FILE_SIZE": "{{size}}", + "NOT_FOUND_TITLE": "Download not found", + "NOT_FOUND_BODY": "This download is no longer available.", + "BACK": "Back to Downloads" +} +``` + +Use Russian translations in `ru.json` and English fallbacks in locales without +an established translation for the new copy. + +- [ ] **Step 2: Update canonical behavior docs** + +Document: + +- focused download item routes and hidden context panel; +- local-only movie/episode playback; +- offline-only series projection; +- safe metadata snapshot and legacy backfill; +- provider-only handoff semantics for Xtream and all Stalker series modes. + +Update `CLAUDE.md` because its route inventory must include the three +`downloads/:downloadId` variants and its Download Manager feature summary must +replace the old provider-detail/Play-from-source behavior. `AGENTS.md` does not +enumerate these routes or this feature contract, so leave it unchanged. + +- [ ] **Step 3: Update the existing release note** + +Keep the note body under 400 characters and user-facing: + +```md +Downloads now open movies and series in focused offline details with saved +metadata and optional TMDB enrichment. Series show only episodes that are +actually available locally, while View in portal opens the complete online +catalog and provider playback. +``` + +- [ ] **Step 4: Validate JSON, formatting, and release notes** + +Run: + +```bash +node -e 'const fs=require("node:fs"); for(const file of fs.readdirSync("apps/web/src/assets/i18n").filter(file=>file.endsWith(".json"))){JSON.parse(fs.readFileSync(`apps/web/src/assets/i18n/${file}`,"utf8"));} console.log("All locale JSON files parse successfully.")' +pnpm run release:notes:validate +pnpm exec prettier --check apps/web/src/assets/i18n docs/architecture CLAUDE.md .changes/downloads-manager-mvp.md +``` + +Expected: locale parse succeeds, all release notes are valid, and Prettier +reports no differences. + +- [ ] **Step 5: Commit docs and copy** + +```bash +git add apps/web/src/assets/i18n docs/architecture CLAUDE.md .changes/downloads-manager-mvp.md +git commit -m "docs(downloads): document offline detail views" +``` + +--- + +### Task 12: Extend Electron E2E and Run the Final Validation Ladder + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/downloads.e2e.ts` + +- [ ] **Step 1: Extend the existing end-to-end journey** + +After a fixture movie completes: + +```ts +await card.getByRole('button', { name: /open details/i }).click(); +await expect(page.getByText('Available offline')).toBeVisible(); +await expect(page.getByRole('button', { name: 'Play offline' })).toBeVisible(); +await expect(page.getByText('Play from source')).toHaveCount(0); +await expect(contextPanel).toHaveCount(0); +``` + +Use fixture/mock IPC to assert local Play receives the finalized file path. +Click `View in portal`, assert the provider detail and category context return, +and assert the provider-only view has no Offline/local actions. + +For a Stalker series fixture, download two non-contiguous episodes and assert +the offline route renders exactly those two rows. Remove one finalized file and +assert the detail returns to Needs attention. + +- [ ] **Step 2: Run focused E2E and fix only observed failures** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +``` + +Expected: all downloads E2E cases pass. + +- [ ] **Step 3: Run the affected unit suites** + +Run: + +```bash +pnpm nx test database --runInBand +pnpm nx test electron-backend --runInBand +pnpm nx test shared-interfaces --runInBand +pnpm nx test services --runInBand +pnpm nx test workspace-shell-util --runInBand +pnpm nx test portal-downloads-feature --runInBand +pnpm nx test portal-shared-util --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +pnpm nx test portal-stalker-data-access --runInBand +pnpm nx test ui-playback --runInBand +``` + +Expected: all suites pass with zero failed tests. + +- [ ] **Step 4: Run lint and build** + +Run: + +```bash +pnpm nx lint database +pnpm nx lint electron-backend +pnpm nx lint shared-interfaces +pnpm nx lint services +pnpm nx lint workspace-shell-util +pnpm nx lint portal-downloads-feature +pnpm nx lint portal-shared-util +pnpm nx lint portal-xtream-feature +pnpm nx lint portal-stalker-feature +pnpm nx lint ui-playback +pnpm nx run web:build:electron-e2e --skipNxCache --outputStyle=static +pnpm run release:notes:validate +git diff --check +``` + +Expected: no lint errors, build succeeds, release notes validate, and diff +check is empty. + +- [ ] **Step 5: Perform manual Electron CDP verification** + +Start: + +```bash +IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 pnpm nx serve electron-backend +``` + +Connect with: + +```bash +agent-browser --cdp 9222 tab list +``` + +Verify global and scoped Downloads, movie/series focused details, absence of +the category panel, local Play, provider handoff, light/dark themes, and +keyboard focus. Do not expose real playlist titles, URLs, or credentials in +logs or screenshots. + +- [ ] **Step 6: Commit the regression journey** + +```bash +git add apps/electron-backend-e2e/src/downloads.e2e.ts +git commit -m "test(downloads): cover offline detail journeys" +``` + +- [ ] **Step 7: Push the existing draft PR branch** + +```bash +git status --short +git push origin agent/download-manager-mvp +gh pr view 1313 --json url,isDraft,state,headRefName +``` + +Expected: branch and remote HEAD match; draft PR #1313 remains open. diff --git a/docs/superpowers/specs/2026-07-30-download-manager-file-availability-design.md b/docs/superpowers/specs/2026-07-30-download-manager-file-availability-design.md new file mode 100644 index 000000000..53c542351 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-download-manager-file-availability-design.md @@ -0,0 +1,264 @@ +# Download Manager File Availability and Card Cleanup + +**Status:** Approved +**Date:** 2026-07-30 + +## Context + +The Download Manager currently treats every persisted `completed` row as +ready to watch. That is only historical transfer state: the finalized file may +have been deleted or its external volume may be unavailable. The main process +checks the filesystem only after Play or Show in folder, so an unavailable +item remains in `Ready to watch` until an action fails. + +The completed-library cards also repeat information that is already implied by +the section: + +- every card carries an `Offline` badge under the `Ready to watch` heading; +- the source label permanently occupies the narrow metadata row. + +This follow-up makes the library reflect current file availability, simplifies +the cards, and preserves source information in the existing overflow menus. +It supersedes the missing-file non-goal in the original Download Manager MVP +spec without adding a background filesystem watcher or database migration. + +## Decisions + +- `Ready to watch` contains only completed downloads whose finalized files are + currently available as regular, non-symbolic-link files. +- A completed row whose file is unavailable appears in `Needs attention` with + a `File missing` status. +- File availability is derived by the Electron main process and is not + persisted as a replacement download status. +- `Download again` revalidates the file and requeues the existing download row + at its retained destination when the file is still unavailable. +- The Download Manager removes the `Offline` badge from completed cards. + Provider detail pages retain their `Offline` indicator for available local + files. +- The source label moves from always-visible card/row metadata into the top of + the overflow menu. +- Existing Stalker series detail navigation remains unchanged and gains + regression coverage for both ordinary series and VOD-series routes. + +## Goals + +- Never describe an unavailable local file as ready to watch. +- Keep temporary volume disconnection recoverable without permanently + rewriting transfer history. +- Offer an immediate, understandable recovery action. +- Keep available members of a partially missing series playable. +- Remove redundant card chrome while keeping file size and source provenance + reachable. +- Ensure missing files cannot make detail views advertise local playback. +- Reuse existing Material components, theme tokens, queue rows, dialogs, + pending-state handling, and download destination rules. + +## Non-goals + +This change does not add: + +- a continuous filesystem watcher; +- persisted availability columns or a download-table migration; +- automatic redownload without an explicit user action; +- folder relocation or a search-for-file workflow; +- offline metadata caching or provider-independent detail pages; +- bulk redownload for a partially missing series; +- automatic deletion of a missing download record; +- changes to provider playback or Stalker/Xtream route structure. + +## Availability contract + +`DownloadItem` gains a renderer-visible availability value: + +```ts +type DownloadFileAvailability = 'available' | 'missing' | 'not-applicable'; +``` + +The Electron main process decorates rows returned by `DOWNLOADS_GET_LIST` and +`DOWNLOADS_GET`: + +- `available` — status is `completed`, `filePath` is present, and `lstat` + identifies a regular non-symbolic-link file; +- `missing` — status is `completed`, but the path is absent, inaccessible, a + directory, or a symbolic link; +- `not-applicable` — the download is not completed. + +The value is computed for each list request and never written into SQLite. +This distinction matters for removable volumes: reconnecting a volume and +refreshing the list restores `available` without repairing database state. + +Filesystem inspection stays in the trusted main process. Renderer code does +not receive a new arbitrary-path probe and cannot request checks for unmanaged +files. + +## View-model partitioning + +The pure Download Manager view model partitions rows in this order: + +1. `queued`, `downloading`, and `paused` enter `Downloading now`; +2. `failed` and `canceled` enter `Needs attention`; +3. `completed + missing` also enters `Needs attention` with a derived + missing-file presentation; +4. only `completed + available` enters `Ready to watch`. + +Missing completed items retain their persisted `completed` status. The +attention-row view model carries a distinct presentation reason so the queue +component does not pretend that the transfer itself failed. + +For series: + +- available episodes continue to group by `(playlistId, seriesXtreamId)` in + `Ready to watch`; +- each unavailable episode appears as a concrete `File missing` row in + `Needs attention`, because recovery operates on one finalized file; +- if every episode is unavailable, the series has no ready card; +- a partially available series card reports only its currently available + members and aggregate available bytes. + +Filter and search semantics remain unchanged. Missing VOD rows match Movies, +missing episode rows match Series, and source name remains searchable even +though it is no longer always visible. Category counts reflect the entities +the page renders; each missing episode is an attention entity. + +## Missing-file row + +The existing queue-row component renders a derived missing-file variant: + +- poster, title, episode label when applicable, and tracked size; +- amber `File missing` status; +- restrained, token-based muted treatment without reducing text contrast + below the normal queue-row contract; +- `Download again` as the primary recovery action; +- overflow menu containing source provenance, Copy URL, and Remove from + manager. + +Artwork and title may still open provider details when provider navigation is +available. They never attempt local playback. Play and Show in folder are not +rendered for a missing item. + +If the source playlist is no longer available, the existing source-missing +navigation behavior remains authoritative. Local recovery can still use the +stored download request metadata. + +## Download-again flow + +Recovery uses an explicit main-process command rather than overloading the +existing failed-download Retry contract: + +1. The renderer sends the managed download ID, never a caller-selected file + path. +2. The main process reloads the row and requires persisted status + `completed`. +3. It rechecks the finalized path. +4. If the file has reappeared, it performs no network request, returns a + successful recovered result, and causes the renderer to refresh. +5. If the file is still unavailable, it requeues the existing row using the + retained destination and existing request URL/header metadata. +6. Existing destination reservation, no-overwrite, authorization, partial + cleanup, queue serialization, broadcast, and structured-error rules remain + authoritative. + +The retained destination follows the existing retry/resume ownership rule: +changing the preferred download folder does not relocate a previously owned +row. If the retained directory itself is unavailable, recovery returns a +structured failure and the row remains in `Needs attention`. + +The item participates in the existing pending-ID set, so repeated clicks are +disabled until the operation settles. Successful requeueing moves it to +`Downloading now` after the backend broadcast. A failed Play or Reveal caused +by a file deleted after the last list load triggers a list refresh, so the row +moves to `Needs attention` immediately after that failure. + +## Completed-card cleanup + +Ready cards keep the shared cover-size and content-grid contracts. + +- Remove the `Offline` artwork badge from movie, grouped-series, and + standalone-episode cards. +- Remove the source label and separator from the permanent metadata row. +- Keep tracked size visible. +- Keep movie Play and Show in folder actions unchanged for available files. +- Put a small non-interactive `Source` label and the resolved playlist title at + the top of the movie overflow menu, followed by Copy URL and Remove from + manager. +- Give grouped-series cards an overflow menu with the same source header and + an action to open downloaded episodes. Per-episode file actions remain in + the existing series dialog. +- Put source provenance at the top of missing-file and other queue-row overflow + menus instead of keeping it in the row metadata. + +The source header truncates long values, exposes the full value through +accessible text or tooltip, and uses existing typography/color tokens. It is +informational rather than a disabled Material menu item, so screen readers do +not announce it as an unavailable command. + +## Detail playback + +`DownloadsService.isDownloaded()` and `getDownloadedFilePath()` require +`fileAvailability === 'available'`. Therefore: + +- an available completed item keeps the `Offline` detail indicator and local + primary Play action; +- a missing completed item does not expose local playback or identify itself + as offline on a detail page; +- provider playback remains available when the provider item is usable. + +This prevents the detail page from reintroducing the same misleading state +after the item has correctly moved out of `Ready to watch`. + +## Series navigation regression + +Manual Electron reproduction on the current build confirmed that Stalker +series cards no longer navigate to Recently viewed: + +- a VOD-series download opened the canonical Stalker `vod/vod` detail route; +- an ordinary series download opened the canonical Stalker `series/series` + detail route. + +The implementation therefore changes no routing production code unless a +failing regression test reveals a separate case. Focused tests must prove that +both series shapes call the canonical detail target and never navigate to a +recent route. + +## Error handling and refresh + +- A filesystem inspection error is treated as `missing` and does not crash or + reject the entire list. +- A recovery race in which the file reappears is resolved in favor of the + existing file; IPTVnator never overwrites it. +- A deleted file detected by Play or Reveal shows the existing file-not-found + feedback and refreshes the list. +- A network, folder, or queue error during Download again uses the existing + action-error snackbar and leaves the item in `Needs attention`. +- Removing a missing entry removes only the manager row and any owned partial; + there is no finalized media file to delete. + +## Testing + +The implementation follows red-green TDD and adds coverage at the closest +ownership boundaries: + +- Electron event tests for list decoration, regular-file validation, + unavailable paths, reappeared-file races, and Download again; +- `DownloadsService` tests for the availability-aware local-playback contract + and refresh after file-action failure; +- pure view-model tests for missing movies, fully missing series, partially + missing series, search, filters, counts, and input immutability; +- queue component tests for File missing presentation, actions, source menu, + pending state, and accessibility; +- library component tests proving Offline/source removal and source placement + in movie and series menus; +- container tests for recovery wiring and file-not-found refresh; +- Stalker navigation regressions for both canonical series detail shapes; +- Electron E2E covering an unavailable finalized file moving to + `Needs attention`, a successful Download again transition, and available + cards remaining in `Ready to watch`; +- affected project tests, lint, Electron web build, release-note validation, + and manual light/dark Electron inspection. + +## Documentation and release note + +The implementation updates `docs/architecture/download-manager.md` to make +filesystem-derived readiness canonical and adds a user-facing release note. +The original MVP spec remains historical context; this approved follow-up is +the source of truth for missing-file presentation and card metadata cleanup. diff --git a/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md b/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md new file mode 100644 index 000000000..47d8f0f39 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md @@ -0,0 +1,515 @@ +# Download Manager MVP Redesign + +**Status:** Approved +**Date:** 2026-07-30 + +## Context + +The current desktop download manager renders every download as the same +management card. The approved handoff in `IPTVnator Download Manager.html` +instead separates work that needs attention from content that is ready to +watch: + +1. a dense queue for active and interrupted transfers; +2. a poster library for completed movies and series. + +This MVP adopts that composition while retaining the existing SQLite schema, +Electron IPC surface, queue semantics, portal routes, and local-file actions. +It deliberately does not present mock data for capabilities the application +cannot currently measure. + +Because `DownloadsService` is a root singleton consumed by the workspace shell +and content detail views, its `downloads` signal always contains the global +download list. Playlist scope is a view concern and never replaces that shared +signal with a partial list. + +## Goals + +- Give active transfers a compact, scannable `Downloading now` queue. +- Present completed downloads as a familiar VOD-style `Ready to watch` + library. +- Make the global `/workspace/downloads` page useful across all sources while + preserving existing playlist-scoped portal routes. +- Add functional All, Movies, Series, and In progress filters. +- Group completed episodes from the same source series into one library card. +- Follow the global Small, Medium, or Large cover-size preference in both the + completed library and its loading skeleton. +- Open existing provider detail views from navigable movie and grouped-series + artwork or titles, while keeping the explicit card Play action immediate and + local. +- Prefer the completed local file from a downloaded movie detail view, with + provider playback remaining an explicit secondary action. +- Keep every existing pause, resume, cancel, retry, play, reveal, copy, and + remove operation reachable. +- Make file-retention behavior explicit instead of implying that a completed + media file is deleted when only its manager entry is removed. +- Reuse IPTVnator's existing light/dark theme tokens, typography, icons, + layout mixins, and empty-state components. +- Split the oversized page into focused standalone Angular components with a + pure, unit-tested view-model layer. + +## Non-goals + +This MVP does not add: + +- recordings to the download library; +- offline connectivity detection, startup redirects, persisted metadata + caching, or source-independent offline detail pages; +- disk-capacity or free-space statistics; +- transfer speed or ETA calculation; +- pause-all, resume-all, queue priority, or queue reordering; +- missing-file scans, folder relocation, or repair flows; +- a new download-history or delete-file-but-keep-history model; +- preflight disk-space checks or partial-season selection; +- new download database columns or Electron download IPC commands. + +## Ownership and routing + +`WorkspaceShellComponent` continues to own the application header, global +search, primary navigation rail, drag regions, and the global Downloads +shortcut. The download feature renders only the center content area. + +- `/workspace/downloads` loads all download rows. +- `/workspace/xtreams/:id/downloads` and + `/workspace/stalker/:id/downloads` retain their current playlist scope. +- All three routes render the same queue/library composition. +- `DownloadsService.loadDownloads()` always refreshes the global list. A + scoped page applies its route playlist ID only in the pure view model. +- The workspace Downloads badge continues to count global queued/downloading + rows. The page-title badge counts queued/downloading rows within the current + route scope. +- Existing portal collection context remains the category source of truth. + Inline filter chips read and update that same selected category, so scoped + shell context controls and the page cannot diverge. +- The workspace `?q=` search parameter remains the search source of truth. + +No shell chrome from the handoff HTML is duplicated inside the feature. + +## Angular component boundaries + +The feature remains inside the existing `portal-downloads-feature` Nx project. +It is decomposed by responsibility: + +- `DownloadsComponent` is the smart container. It owns route scope, playlist + lookup, collection context, navigation, dialogs, snackbars, and calls into + `DownloadsService`. It never requests a playlist-filtered replacement for + the root service signal. +- A pure download-manager view-model module filters, partitions, sorts, counts, + and groups `DownloadItem` values without Angular or service dependencies. +- A queue-list component renders section headings and individual queue rows. +- A library-grid component renders completed movie and grouped-series cards. +- A downloaded-series dialog renders the concrete episode files within one + grouped series and exposes their file actions. +- Presentational children use signal inputs/outputs, OnPush change detection, + and typed user-intent events. They do not inject `DownloadsService`, + `Router`, dialogs, or snackbars. + +Production TypeScript files stay below the repository's new-file line limit. +The existing max-lines baseline entry for `downloads.component.ts` must shrink +or disappear after the split; no new file is added to the baseline. + +## View-model pipeline + +The container derives the rendered model through one deterministic pipeline: + +1. Apply the optional route playlist scope. +2. Resolve a source label from the loaded playlist map. +3. Apply the normalized workspace search term. +4. Apply the selected content/status filter. +5. Partition rows into active queue, attention queue, and completed library. +6. Sort queue entries by parsed `createdAt` ascending and then numeric `id` + ascending. Sort library entries by their newest member timestamp descending + and then stable entity key. +7. Group completed episode rows into series cards. + +The view model is recomputed from signals and is never persisted separately. +Electron broadcasts and the subsequent `downloadsGetList()` response remain +authoritative. Missing or invalid timestamps normalize to zero for ordering. + +### Filter semantics + +| Filter | Queue | Attention | Library | +| ----------- | ------------------------------------ | ------------------- | ------------------------- | +| All | Movies and episodes | Movies and episodes | Movies and grouped series | +| Movies | VOD rows | VOD rows | Completed VOD cards | +| Series | Episode rows | Episode rows | Grouped completed series | +| In progress | Queued, downloading, and paused rows | Hidden | Hidden | + +Chip counts are calculated from the scoped, partitioned, and grouped model +before text search, so typing in the workspace search does not make category +totals jump: + +- All counts the entities the unsearched page would render: active rows, + attention rows, completed movie cards, grouped series cards, and ungrouped + completed-episode cards. +- Movies counts VOD rows/cards across all three partitions. +- Series counts active/attention episode rows plus grouped or ungrouped + completed-series entities. +- In progress counts queued, downloading, and paused rows. + +Search matches the stored item title, derived series title, source name, +episode label, and error message. + +### Queue partitions + +- `Downloading now` contains `queued`, `downloading`, and `paused`. +- `Needs attention` contains `failed` and `canceled`. +- A section is omitted when it has no rows. +- `Ready to watch` contains only `completed`. + +This avoids describing failed or canceled rows as currently downloading while +keeping them close to the queue actions that repair or dismiss them. + +## Completed-series grouping + +Completed episode rows with a positive safe-integer `seriesXtreamId` are +grouped by `playlistId + seriesXtreamId`. This prevents identical provider IDs +from different playlists from merging. + +- Episodes without a positive safe-integer `seriesXtreamId` fall back to + individual episode cards. +- The group title is derived from the standardized stored title prefix before + `- SxxExx -`. If a legacy title does not match that form, the first + non-empty member title is used unchanged. +- Artwork uses the newest member with a valid poster URL. +- The group exposes episode count, downloaded season range, aggregate tracked + bytes, source label, and newest member timestamp. +- Members are ordered by season number, episode number, then creation time. +- Aggregate tracked bytes are the sum of each member's + `bytesDownloaded ?? 0`. + +Clicking the series artwork or title opens the existing source series detail +view. That view already marks downloaded episodes and exposes local playback. +A separate `N episodes` control opens a compact downloaded-series dialog so +Play, Show in folder, Copy URL, and Remove from manager remain available for +each concrete file. + +If the source playlist has been removed, detail navigation is disabled with +the existing explanatory tooltip only as a defensive corrupted/legacy-data +guard. Normal playlist deletion cascades to download rows, so an orphaned +download is not a supported user-visible state. + +An ungrouped completed episode shows its stored title, `SxxExx` label when +season/episode values are usable, Episode type, source, poster, and tracked +size. Because it lacks a trustworthy series identity, it does not claim to +open series details. Its artwork, title, and explicit Play action all keep the +direct-local fallback. Show in folder, Copy URL, and Remove from manager remain +available for that concrete file. + +## Page composition + +The feature owns one fixed header region and one scrolling content region. + +### Header region + +1. Page title and current-scope queued/downloading count. +2. Current download folder, truncated in the middle-safe available width and + exposed in full through tooltip/title text. +3. `Change folder`. +4. `Clear finished` when completed, failed, or canceled rows exist. +5. A compact tracked-data summary with the folder and total byte progress + recorded across the rows still owned by the manager. +6. All, Movies, Series, and In progress filter chips. + +The summary says `Tracked downloads`, not disk used or disk free. Its value is +the sum of `bytesDownloaded ?? 0` for every row in the current route scope. +This is database-recorded byte progress, so it is not presented as an exact +filesystem measurement and has no percentage meter. Exact disk reconciliation +belongs to the missing-file/storage work outside this MVP. + +### Scrolling region + +1. `Downloading now`, if present. +2. `Needs attention`, if present. +3. `Ready to watch`, if present. +4. The relevant compact empty state when a selected filter or search has no + matches. + +Queue sections and the library share one scroll owner. The header, storage +summary, and filters remain visible. + +## Queue-row behavior + +Every queue row contains: + +- poster thumbnail or content-type placeholder; +- title; +- episode label when applicable; +- source label; +- semantic status pill; +- downloaded and total byte values; +- determinate progress when `totalBytes` is known, otherwise indeterminate + progress for an active transfer; +- status-specific actions. + +| Status | Primary actions | +| ----------- | ----------------------------------- | +| queued | Pause, Cancel | +| downloading | Pause, Cancel | +| paused | Resume, Cancel, Remove from manager | +| failed | Retry, Remove from manager | +| canceled | Retry, Remove from manager | + +Copy URL is placed in an overflow menu rather than competing with the primary +transfer controls. Clicking the title/artwork opens the corresponding source +detail when navigation is available. Action controls stop event propagation. + +An item-level pending set disables repeated commands until each promise +settles. The UI does not optimistically change status; the backend result and +subsequent broadcast drive the rendered transition. Structured failures use +the existing snackbar path. + +## Library-card behavior + +Movie and series cards use the shared content-grid dimensions and two-to-three +poster ratio. The grid and completed-library skeleton consume +`--cover-grid-min-width` and `--cover-gap` through the shared content-grid +contract. They do not own a fixed card width, gap, or mobile column count. +Each card shows: + +- poster or semantic placeholder; +- `Offline` badge; +- title; +- movie or series label; +- tracked size; +- source label; +- episode count and season range for grouped series. + +Navigable movie and grouped-series artwork or titles open the existing source +detail view. The explicit movie Play button starts the finalized local +`filePath` without navigating; Show in folder stays alongside it, while Copy +URL and Remove from manager live in an overflow menu. Grouped-series cards +expose source details and the downloaded-episodes dialog described above. +Legacy ungrouped episodes retain the direct-local fallback described in +Completed-series grouping. + +Touch and keyboard users can reach every action without relying on hover. + +## Downloaded movie detail behavior + +Existing Xtream and shared Stalker VOD detail routes remain provider-backed; +this change does not cache their metadata or make the detail page independent +of its source. + +When the detail view finds a matching completed download: + +- the primary idle action is `Play Local` and includes an explicit `Offline` + label; +- that action resolves and opens the completed local file; +- provider playback remains available as a secondary `Play from this source` + action, preserving its existing resume, pinned-source, and player behavior; +- an owned MPV/VLC session in `launching`, `opened`, or `playing` state keeps + the existing opening/Stop primary control until that session settles. + +When no matching completed download exists, Play/Resume/Restart behavior is +unchanged. + +## Removal and clearing semantics + +The current backend removes the download database row and any retained +`.part` file, but it does not delete an already finalized media file. The MVP +does not change that contract. + +Therefore: + +- `Remove` becomes `Remove from manager`. +- `Clear Completed` becomes `Clear finished`. +- Removing a completed entry says that its finalized media file remains on + disk. +- Removing a paused, failed, or canceled entry warns that any retained partial + download is deleted and can no longer be resumed. +- `Clear finished` says that finalized media files remain, while retained + partial data belonging to failed/canceled rows is deleted. +- The completed-card action does not use a trash/delete-file icon or claim to + reclaim disk space. +- The tracked-data summary includes only rows still owned by the manager. Once + a row is cleared, its finalized file may remain on disk but its recorded byte + progress is no longer included. + +Changing this behavior requires a separate filesystem/history design with +explicit delete and recovery semantics. + +## Loading, empty, and error states + +- Desktop-unavailable messaging remains for runtimes without the download + bridge. +- Playlist loading and download loading render skeletons shaped like the new + queue and poster sections. +- A profile with no playlists keeps the existing add-playlist/source actions. +- A profile with playlists but no downloads keeps the storage summary visible, + explains that downloads begin from movie or episode details, and links back + to the dashboard. +- A filter or search miss uses a compact inline empty state without replacing + the whole page. +- Broken poster URLs switch once to the semantic placeholder. +- File-not-found and file-action errors continue to use translated snackbars. +- No connectivity-specific state is inferred in this MVP. + +## Theme and visual language + +The handoff's standalone `tokens.css` is a visual reference, not a new runtime +token source. Its roles map to existing IPTVnator variables: + +| Handoff role | IPTVnator source | +| -------------------------- | -------------------------------------------------------- | +| page background | `--app-content-bg` | +| panel/card background | `--app-widget-bg` | +| raised/secondary surface | `--app-widget-header-bg`, `--app-card-hover-bg` | +| primary and secondary text | `--app-heading-color`, `--app-body-color` | +| muted text | `--app-muted-color`, `--app-eyebrow-color` | +| active blue | `--app-selection-color` and `--app-selection-*` surfaces | +| separators | `--app-separator`, `--app-widget-header-border` | + +The implementation reuses DM Sans, JetBrains Mono for compact byte/path +metadata, Material icons, the shared `content-grid` Sass mixin, and its global +`--cover-grid-min-width` / `--cover-gap` inputs. +`GridListComponent` itself is not reused because its fixed +poster/rating/title contract cannot expose download badges and file actions +without widening a shared portal API for unrelated consumers. + +No parallel surface/text token set is added. Semantic status styling reuses +the application's established download/error colors and always pairs color +with text and iconography. + +## Responsive behavior + +- Wide layouts keep queue metadata, status, progress, and controls on one row. +- Below the feature's medium container width, progress and byte metadata move + below the title while controls remain trailing. +- Narrow layouts stack the page heading and folder actions. +- Filter chips become a horizontally scrollable, keyboard-reachable row rather + than wrapping into several uneven lines. +- The poster grid follows the global cover-size tokens, including the app's + existing responsive preference behavior. It may wrap the minimum width in + `min(100%, token)` only to prevent overflow. +- On touch-sized layouts, card actions remain visibly reachable instead of + appearing only on hover. +- Content owns one vertical scroll region and never introduces a competing + nested list scroll. + +Animations are short surface/transform transitions and are removed under +`prefers-reduced-motion`. + +## Accessibility + +- Sections use headings and named regions. +- Clickable artwork/title controls are real links or buttons, not click-only + `div` elements. +- Icon buttons have translated accessible names and visible focus states. +- Progress bars expose mode, current value, and a useful item label. +- Byte progress updates do not use a live region every 500 ms. +- Status changes and command failures are announced without duplicating every + progress tick. +- Poster images use meaningful or deliberately empty alternative text based on + whether adjacent text already supplies the title. +- Status is never conveyed by color alone. +- Series dialog focus is trapped and restored by Angular Material, supports + Escape, and labels every episode action with its episode identity. + +## Test strategy + +The feature receives its own Jest target/config, following an existing Angular +library in the workspace. + +### Unit coverage + +Pure view-model tests cover: + +- global and playlist-scoped filtering; +- all four filter modes and stable counts; +- search across title, derived series title, source, episode, and error text; +- queue/attention/library partitioning; +- deterministic ordering; +- cross-playlist series separation; +- legacy or missing series metadata fallback; +- poster and title selection; +- episode count, season range, and aggregate tracked bytes. + +Focused component tests cover: + +- typed action emission for every status; +- movie and grouped-series detail emission versus nested explicit local Play; +- the direct-local legacy episode fallback; +- completed-library and skeleton consumption of the global cover-size tokens; +- downloaded movie details preferring local playback while retaining an + explicit provider-source action; +- pending/disabled controls; +- no navigation from nested action buttons; +- keyboard access to cards and filter chips; +- grouped-series dialog actions; +- completed-row versus partial-row confirmation copy; +- loading and compact empty states. + +Adding the feature test target also updates the corresponding +`tools/coverage/coverage-policy.json` entry so it no longer claims that the +project has no test target. + +### Electron end-to-end coverage + +`apps/electron-backend-e2e/src/downloads.e2e.ts` moves from styling-class and +icon-text locators to roles and stable `data-testid` values. It verifies: + +- first-run/no-playlist state; +- selected folder presentation; +- two source playlists remain visible globally while each scoped route shows + only its own rows, without changing the global workspace badge; +- workspace `?q=` search and each filter produce the specified partitions; +- an active transfer appears in `Downloading now`; +- pause retains its partial and resume completes through HTTP Range; +- completion moves the item into `Ready to watch`; +- movie artwork/title opens the exact source detail route while explicit card + Play still targets the local file; +- Small, Medium, and Large cover preferences change completed-card geometry; +- multiple completed episodes render one grouped series card whose details and + downloaded-episodes dialog target the correct playlist/series; +- a downloaded movie detail presents local playback as the primary idle action + and keeps provider playback secondary; +- Play and Show in folder remain available; +- removing a completed row preserves the finalized file; +- removing/clearing a retained failed or canceled row deletes its `.part`. + +Backend unit suites are rerun only if implementation reveals an unintended +contract change; the approved design does not require one. + +### Visual and build validation + +- Feature lint and unit tests. +- Services tests if service helpers change. +- Web typecheck and Angular/Nx production build. +- Targeted Electron downloads E2E. +- Release-note and i18n validation. +- Running Electron inspection at wide/narrow dimensions, all three cover-size + preferences, and light/dark themes through `agent-browser`. +- A final native-window inspection through Computer Use. + +Any documentation screenshot uses only the repository's mock servers and +release-capture workflow; real playlist artwork, metadata, streams, and +credentials are prohibited. + +## Documentation and release impact + +- Update `docs/architecture/download-manager.md` to describe the queue/library + view model, grouping behavior, route scope, and honest removal semantics. +- Update affected `CLAUDE.md` download-manager text only if an existing claim + becomes stale after implementation. +- Add a user-facing `.changes/downloads-*.md` note and validate it. +- Assess the README download-manager screenshot. Refresh it only through a + mock-backed release capture; otherwise record why capture support remains a + follow-up. + +## Acceptance criteria + +The MVP is complete when: + +1. active work, attention states, and ready content are visually distinct; +2. global and scoped routes preserve their intended data boundaries; +3. filters, search, queue actions, movie/grouped-series detail navigation, + direct card Play, downloaded-detail local preference, and all local file + actions work with current backend contracts; +4. removal and clearing preserve finalized media, delete retained partial data + where the existing backend does so, and explain both outcomes accurately; +5. the screen is intentional in light/dark and wide/narrow layouts, and its + cards honor the global Small/Medium/Large cover preference; +6. keyboard and screen-reader semantics do not depend on hover or color; +7. unit, build, targeted Electron E2E, i18n, and release-note validation pass; +8. canonical download-manager documentation and the release note are current. diff --git a/docs/superpowers/specs/2026-07-31-download-manager-offline-details-design.md b/docs/superpowers/specs/2026-07-31-download-manager-offline-details-design.md new file mode 100644 index 000000000..956ca0de4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-download-manager-offline-details-design.md @@ -0,0 +1,344 @@ +# Download Manager Offline Details Design + +## Status + +Approved for implementation on 2026-07-31. + +## Context + +Download Manager ready cards currently navigate into the provider's canonical +catalog detail flow. That fixed the earlier accidental playback-on-card-click +behavior, but it also reintroduces the playlist category panel and provider +catalog context. Stalker series opened from a sparse persisted download can +also lack the metadata that Favorites and Recently Viewed retain. + +The download library needs a distinct offline detail experience. It must make +the local file authoritative, remain useful without network access, and offer +an explicit escape hatch into the full provider catalog when the user wants +online context. + +## Goals + +- Open downloaded movies and series in a full-width, collection-owned offline + detail view without the playlist category panel. +- Reuse the metadata quality and visual language of existing Xtream and + Stalker detail views, including optional TMDB enrichment. +- Keep movie playback explicitly local. +- Show only locally available seasons and episodes for downloaded series. +- Provide an explicit `View in portal` action that opens the normal provider + detail with the category panel, full episode catalog, and provider playback. +- Preserve useful metadata when the portal or TMDB is unavailable. +- Keep old downloads functional even though they predate metadata snapshots. + +## Non-goals + +- Building a second provider catalog inside Download Manager. +- Streaming an episode from the offline detail view. +- Showing unavailable provider episodes as disabled offline rows. +- Downloading a full season or series in one action. +- Replacing the existing Xtream or Stalker provider detail architecture. +- Changing normal provider-detail behavior when it was not opened through the + offline view's `View in portal` action. + +## Approved UX + +### Navigation + +Ready cards navigate to a dedicated offline detail route owned by Download +Manager. The route remains inside the current global or provider-scoped +downloads context: + +- `/workspace/downloads/:downloadId` +- `/workspace/xtreams/:playlistId/downloads/:downloadId` +- `/workspace/stalker/:playlistId/downloads/:downloadId` + +For a grouped series card, `downloadId` is the representative completed +episode. The detail resolver uses its `(playlistId, seriesXtreamId)` identity +to load the complete locally available group. + +The workspace shell treats a downloads item route as a focused detail route: +the collection/category context panel and collection search controls are +hidden. Back returns to the Download Manager route and preserves its query +search, filter, scope, and browser-history position. + +`View in portal` deliberately leaves the offline route and invokes the +existing provider navigation flow: + +- Xtream opens its canonical movie or series route. +- Stalker opens its existing category/store-state detail flow. + +The provider route restores the playlist category panel and full provider +catalog context. + +### Movie Detail + +The movie screen uses the shared portal detail visual language: + +- Back to Downloads +- artwork/backdrop +- title, description, year, duration, genres, rating, cast, and other available + editorial metadata +- an `Available offline` state +- primary `Play offline` +- secondary `View in portal` beside the primary action +- file-management actions such as Show in folder in the existing overflow + pattern + +There is no `Play from source` action on the offline screen. + +### Series Detail + +The series screen uses the same metadata-rich hero, followed by an offline-only +season and episode list: + +- `View in portal` remains a visible hero action; it is not hidden in overflow; +- only seasons containing at least one currently available local file appear; +- only downloaded episodes whose finalized files are currently available + appear; +- season chips show the number of available offline episodes; +- each row shows the season/episode coordinate, title when known, file size, + and an explicit local Play action; +- episode ordering is natural by season, episode, creation time, and id; +- no provider-only episode appears, even if provider or TMDB metadata returns a + complete season. + +There is no ambiguous series-level streaming action. Playback starts from the +explicit action on an available episode row. + +### Provider-only Handoff + +`View in portal` is navigation, not playback. It opens the existing provider +detail in an explicit provider-only presentation for that handoff: + +- full provider metadata and full season/episode catalog are visible; +- normal provider Play actions are available; +- downloaded/offline badges and local playback actions are suppressed; +- the category panel behaves exactly as it does during normal catalog + browsing. + +The provider-only flag is scoped to this navigation. Opening the same item +normally elsewhere retains the application's existing behavior. + +## Metadata Architecture + +### Provider-neutral Snapshot + +Add a versioned, provider-neutral metadata snapshot to each download row. The +snapshot contains only display data needed by the offline detail view: + +- media kind and stable provider/TMDB identifiers +- title and original title +- description/plot +- release date/year, duration, genres, rating, and status +- poster/backdrop metadata +- bounded cast/creator entries +- parent-series metadata for episode downloads +- episode title, description, still, season number, and episode number +- snapshot language, schema version, and enrichment timestamp + +The snapshot must never contain stream URLs, request headers, credentials, +MAC/device identity, cookies, or other authentication material. Renderer and +main-process boundaries validate the snapshot shape and enforce a bounded +serialized size before persistence. + +Episode rows carry both their episode metadata and the shared parent-series +snapshot. A grouped series selects the newest valid parent snapshot and merges +only missing fields from older valid snapshots. Backfilling a series updates +the matching managed group so later opens do not repeat the same work. + +### Snapshot Creation and Refresh + +New downloads pass the best metadata already present in the detail view into +`DownloadsService.startDownload`. The main process persists the validated +snapshot with the managed download row. + +Opening an offline detail follows this order: + +1. Render the persisted snapshot immediately. +2. If the snapshot is absent, sparse, stale, or in another app language, try a + best-effort provider metadata load through the existing Xtream or Stalker + data-access path. +3. If TMDB is enabled, run the existing `TmdbEnrichmentService` and existing + field-level merge helpers. TMDB remains best-effort and cached according to + the current runtime policy. +4. Persist the validated merged result back to the managed download row or + series group. +5. If provider or TMDB loading fails, retain and display the best local + snapshot. A sparse legacy row still renders title, poster, file size, and + local playback. + +Provider metadata remains authoritative for stream identity and provider-only +fields. TMDB wins only for the same editorial fields it already enriches in +the existing detail views. + +### Legacy Downloads + +No destructive migration is required. Existing rows receive a nullable +snapshot column. Their first offline-detail open attempts the enrichment and +backfill flow. Failure leaves the row playable with its existing +title/poster/file metadata. + +## Component Boundaries + +### Download Offline Detail Route + +A focused route component owns: + +- resolving a managed download by id; +- validating that it is completed and locally available; +- resolving grouped-series members; +- choosing movie versus series presentation; +- preserving the return URL; +- coordinating metadata enrichment without blocking initial local rendering. + +It does not own provider API details or download IPC implementation. + +### Offline Detail Presentation + +Provider-neutral movie and series presentation components consume a canonical +offline detail view model. They reuse existing shared detail-shell, +artwork/metadata, cast, and Material controls where practical. They emit only: + +- back +- play local item +- reveal local item +- view in portal + +### Metadata Adapter + +A download metadata service owns: + +- mapping current Xtream/Stalker detail data to the snapshot DTO; +- applying existing provider/TMDB merge helpers; +- choosing and merging series snapshots; +- validating and persisting renderer-generated snapshots through managed IPC; +- producing a view model that never adds provider-only episodes to the + offline list. + +Provider-specific lookup behavior stays in the corresponding portal +data-access/feature boundary. + +### Portal Navigation + +The existing `DownloadLibraryNavigationService` becomes the portal-handoff +service used only by `View in portal`. Ready-card clicks no longer call it. +The handoff carries the provider-only presentation flag and preserves the +current Stalker VOD-series identity rules. + +## Data and IPC Changes + +- Add nullable `metadata_snapshot` storage to the downloads schema and + migrations. +- Extend download start payloads and download list rows with the validated + snapshot type. +- Add a managed metadata-update IPC operation accepting a download id plus a + bounded snapshot. The backend resolves ownership/group identity from the + database rather than trusting renderer-supplied playlist or path values. +- Continue deriving file availability in the main process on every read. +- Do not persist availability into the snapshot or database status. + +## File Availability and Errors + +- A detail route whose row no longer exists shows a focused not-found state + with Back to Downloads. +- A completed row whose file is missing redirects/replaces back to Download + Manager, where the existing Needs attention recovery UI is authoritative. +- A Play race that returns `File not found` refreshes downloads and returns to + the manager without advertising successful playback. +- `View in portal` is disabled with a clear explanation when the source + playlist is gone or a reliable provider target cannot be resolved. +- Provider/TMDB failures never block local playback. +- Corrupt or oversized snapshots are ignored and never crash the detail view. + +## Accessibility and Visual Rules + +- Use existing Material and `--app-*`/Material system tokens; introduce no new + global design tokens. +- Preserve light/dark theme behavior. +- The primary local Play and secondary `View in portal` have distinct labels, + icons, and accessible names. +- Season tabs/chips and episode rows expose selected state, coordinates, and + offline availability to assistive technology. +- Keyboard focus returns predictably on Back and remains visible throughout + the detail surface. +- Reduced-motion preferences continue to disable nonessential transitions. + +## Testing Strategy + +### Unit and Integration + +- route parsing hides the context panel only for a focused downloads item; +- movie and grouped-series card clicks navigate to offline detail; +- Back preserves manager scope, filter, search, and history; +- movie detail exposes local Play and no `Play from source`; +- series view contains only available downloaded seasons/episodes in natural + order; +- missing files never enter the offline episode list; +- `View in portal` produces correct Xtream and all Stalker series-mode targets; +- provider-only handoff suppresses local/offline actions while preserving + provider playback; +- snapshot validation, size limits, migrations, legacy fallback, language + refresh, provider failure, TMDB disabled/enabled, and TMDB merge behavior; +- metadata backfill updates the correct managed movie or series group. + +### Electron E2E + +Extend the existing downloads E2E journey to prove: + +- ready-card click opens a focused detail with no context panel; +- movie Play uses the finalized local file; +- series detail lists only downloaded episodes; +- `View in portal` restores provider/category context; +- a removed local file returns to Needs attention; +- legacy sparse metadata still renders and remains playable. + +Use mock servers and original fixture artwork/metadata only. + +### Manual Verification + +Verify the built Electron app in light and dark themes, all three cover-size +settings on the manager, movie/series offline details, Stalker regular series +and VOD-series modes, provider handoff, and keyboard/focus behavior. + +## Documentation and Release Notes + +Update: + +- `docs/architecture/download-manager.md` +- `docs/architecture/portal-detail-navigation.md` +- `docs/architecture/stalker-portal.md` if the Stalker handoff contract changes +- root living docs only if their described routes or subsystem contracts + change +- the existing Download Manager release note under `.changes/` + +## Alternatives Considered + +### Reuse Provider Detail Directly + +Rejected because it keeps provider catalog state, shows the category panel, +loads all provider episodes, and cannot guarantee an honest offline-only view. + +### Show Full Seasons With Offline Badges + +Rejected because unavailable episodes create visual noise, depend on a live +portal response, and leave playback provenance ambiguous. + +### Put `View in portal` Only in Overflow + +Rejected because the mode switch is important and should remain visible beside +the local Play action. + +## Acceptance Criteria + +- Downloaded movies and grouped series open a focused offline detail without a + category panel. +- Offline movie Play and episode Play always target managed available local + files. +- Offline series show only available downloaded episodes. +- Offline details reuse provider metadata and optional TMDB enrichment, then + persist a safe snapshot for later offline use. +- For movies, `View in portal` sits beside `Play offline`; for series it remains + visible in the hero above the episode list. It opens the provider detail with + complete online context and no offline actions for that handoff. +- Missing files and missing sources degrade honestly without blocking other + local content. diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 6763eb864..584c86464 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -1,6 +1,11 @@ @if (selectedCategory()) { @if (selectedDetailComponent(); as detailComponentRef) { - + } @else {
@@ -67,10 +72,14 @@ type="button" mat-button class="refine-action" - [class.refine-action-active]="activeRefinementCount() > 0" + [class.refine-action-active]=" + activeRefinementCount() > 0 + " [matMenuTriggerFor]="refineMenu" [matTooltip]="'WORKSPACE.REFINE_ACTION' | translate" - [attr.aria-label]="'WORKSPACE.REFINE_ACTION' | translate" + [attr.aria-label]=" + 'WORKSPACE.REFINE_ACTION' | translate + " > tune {{ 'WORKSPACE.REFINE_ACTION' | translate }} @@ -83,7 +92,9 @@ @if (canSortContent()) {
- {{ 'WORKSPACE.REFINE_SORT_SECTION' | translate }} + {{ + 'WORKSPACE.REFINE_SORT_SECTION' | translate + }}
- @for (threshold of ratingThresholds; track threshold) { + @for ( + threshold of ratingThresholds; + track threshold + ) { +
+ +
+ + {{ 'DOWNLOADS.SERIES' | translate }} + + + + +
+ + @if ( + entity.firstSeason !== undefined && + entity.lastSeason !== undefined + ) { + + + @if ( + entity.firstSeason === + entity.lastSeason + ) { + {{ + 'DOWNLOADS.SEASON' + | translate + : { + season: entity.firstSeason, + } + }} + } @else { + {{ + 'DOWNLOADS.SEASON_RANGE' + | translate + : { + first: entity.firstSeason, + last: entity.lastSeason, + } + }} + } + + } +
+ +
+ + + + + +
+
+ + } @else { +
+
+ +
+ +
+ + {{ + (entity.kind === 'movie' + ? 'DOWNLOADS.MOVIE' + : 'DOWNLOADS.EPISODE' + ) | translate + }} + + + @if ( + entity.kind === 'episode' && entity.episodeLabel + ) { + + {{ entity.episodeLabel }} + + } + + +
+ + + + + + + + +
+
+
+ } + } +
+ +} diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss new file mode 100644 index 000000000..d1820c097 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -0,0 +1,249 @@ +@use '../../../../shared/ui/src/lib/styles/content-grid' as grid; + +:host { + display: block; +} + +.download-library { + display: grid; + gap: 14px; +} + +.download-library__header { + h2 { + margin: 0; + color: var(--app-heading-color); + font: var(--mat-sys-title-medium); + font-weight: 680; + letter-spacing: -0.015em; + } +} + +.download-library__grid { + @include grid.content-grid( + $min-width: min(100%, var(--cover-grid-min-width, 148px)), + $gap: var(--cover-gap, 16px) + ); +} + +.download-library__card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--app-separator); + border-radius: 16px; + background: var(--app-widget-bg); + color: var(--app-body-color); + transition: + border-color 160ms ease, + transform 160ms ease, + box-shadow 160ms ease; + + &:hover, + &:focus-within { + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 38%, + var(--app-separator) + ); + box-shadow: 0 12px 28px + color-mix(in srgb, var(--mat-sys-shadow) 14%, transparent); + transform: translateY(-2px); + } +} + +.download-library__artwork { + position: relative; + aspect-ratio: 2 / 3; + overflow: hidden; + background: var(--app-widget-header-bg); +} + +.download-library__artwork-button, +.download-library__title-button, +.download-library__episodes-button { + margin: 0; + border: 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +.download-library__title-hint { + display: block; + min-width: 0; +} + +.download-library__artwork-button { + display: block; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + + img, + .download-library__placeholder { + display: flex; + width: 100%; + height: 100%; + } + + img { + object-fit: cover; + transition: transform 180ms ease; + } + + &:hover:not(:disabled) img, + &:focus-visible:not(:disabled) img { + transform: scale(1.025); + } +} + +.download-library__placeholder { + align-items: center; + justify-content: center; + background: linear-gradient( + 145deg, + color-mix( + in srgb, + var(--mat-sys-primary) 13%, + var(--app-widget-header-bg) + ), + var(--app-widget-header-bg) + ); + color: var(--app-muted-color); + + mat-icon { + width: 42px; + height: 42px; + font-size: 42px; + } +} + +.download-library__body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 7px; + padding: 12px; +} + +.download-library__type { + color: var(--app-eyebrow-color); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.download-library__title-button { + display: -webkit-box; + width: 100%; + min-height: 2.6em; + padding: 0; + overflow: hidden; + background: transparent; + color: var(--app-heading-color); + font-size: 0.93rem; + font-weight: 650; + line-height: 1.3; + text-align: left; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.download-library__facts, +.download-library__metadata { + display: flex; + min-width: 0; + align-items: center; + gap: 5px; + color: var(--app-muted-color); + font-size: 0.72rem; + line-height: 1.3; +} + +.download-library__metadata { + font-family: var(--mat-sys-body-small-font); + + span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span:last-child { + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-variant-numeric: tabular-nums; + } +} + +.download-library__episodes-button { + display: inline-flex; + min-height: 44px; + align-items: center; + margin: -8px -4px; + padding: 8px 4px; + background: transparent; + color: var(--mat-sys-primary); + font-weight: 650; +} + +.download-library__episode-label { + width: fit-content; + padding: 3px 6px; + border-radius: 5px; + background: color-mix( + in srgb, + var(--mat-sys-primary) 11%, + var(--app-widget-header-bg) + ); + color: var(--mat-sys-primary); + font-family: var(--mat-sys-label-small-font); + font-size: 0.7rem; + font-weight: 700; +} + +.download-library__actions { + display: flex; + min-height: 40px; + align-items: center; + justify-content: flex-end; + margin: 2px -6px -6px; + border-top: 1px solid var(--app-separator); + padding-top: 6px; + + button { + color: var(--app-body-color); + } +} + +button:focus-visible { + border-radius: 6px; + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; +} + +button:disabled { + cursor: default; + opacity: 0.48; +} + +@media (max-width: 620px) { + .download-library__body { + padding: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + .download-library__card, + .download-library__artwork-button img { + transition: none; + } + + .download-library__card:hover, + .download-library__card:focus-within { + transform: none; + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts new file mode 100644 index 000000000..61855ad94 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -0,0 +1,543 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { + DownloadLibraryEntity, + DownloadSeriesCardViewModel, +} from './download-library.viewmodel'; +import { DownloadLibraryComponent } from './download-library.component'; + +const TRANSLATIONS = { + DOWNLOADS: { + ARTWORK_UNAVAILABLE: 'Artwork unavailable', + COPY_URL: 'Copy download URL', + EPISODE: 'Episode', + EPISODE_COUNT_ONE: '1 episode', + EPISODE_COUNT_OTHER: '{{count}} episodes', + MORE_ACTIONS: 'More actions', + MOVIE: 'Movie', + OFFLINE: 'Offline', + OPEN_ARTWORK: 'Open details: {{title}} artwork', + OPEN_DETAILS: 'Open details', + OPEN_EPISODES: 'Open downloaded episodes', + PLAY: 'Play', + READY_TO_WATCH: 'Ready to watch', + REMOVE_FROM_MANAGER: 'Remove from manager', + REVEAL: 'Show in folder', + SEASON: 'Season {{season}}', + SEASON_RANGE: 'Seasons {{first}}–{{last}}', + SERIES: 'Series', + }, + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', + }, + }, +}; + +function item(overrides: Partial): DownloadItem { + return { + id: 1, + playlistId: 'playlist-a', + xtreamId: 101, + contentType: 'vod', + title: 'Download', + url: 'https://media.example.test/download', + status: 'completed', + bytesDownloaded: 2_500_000, + ...overrides, + }; +} + +const MOVIE = item({ + id: 9, + title: 'Moonrise', + posterUrl: 'https://media.example.test/moonrise.jpg', +}); +const SERIES_MEMBERS = [ + item({ + id: 11, + xtreamId: 201, + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber: 1, + episodeNumber: 2, + title: 'Northwind - S01E02 - Harbour', + }), + item({ + id: 12, + xtreamId: 202, + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber: 1, + episodeNumber: 5, + title: 'Northwind - S01E05 - Crossing', + }), + item({ + id: 13, + xtreamId: 203, + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber: 2, + episodeNumber: 1, + title: 'Northwind - S02E01 - Return', + }), +] as const; +const SERIES: DownloadSeriesCardViewModel = { + kind: 'series', + key: 'series:playlist-a:77', + representative: SERIES_MEMBERS[2], + members: SERIES_MEMBERS, + seriesXtreamId: 77, + title: 'Northwind', + posterUrl: 'https://media.example.test/northwind.jpg', + newestTimestamp: 3, + sourceName: 'Living room', + trackedBytes: 7_500_000, + firstSeason: 1, + lastSeason: 2, +}; +const FALLBACK_EPISODE = item({ + id: 21, + xtreamId: 301, + contentType: 'episode', + seriesXtreamId: undefined, + seasonNumber: 2, + episodeNumber: 4, + title: 'Legacy episode', + posterUrl: 'https://media.example.test/broken.jpg', +}); +const ENTITIES: readonly DownloadLibraryEntity[] = [ + { + kind: 'movie', + key: 'movie:9', + item: MOVIE, + newestTimestamp: 4, + sourceName: 'Cinema', + trackedBytes: 2_500_000, + }, + SERIES, + { + kind: 'episode', + key: 'episode:21', + item: FALLBACK_EPISODE, + newestTimestamp: 2, + sourceName: 'Archive', + trackedBytes: 2_500_000, + episodeLabel: 'S02E04', + }, +]; + +describe('DownloadLibraryComponent', () => { + let fixture: ComponentFixture; + let component: DownloadLibraryComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + DownloadLibraryComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + TestBed.inject(TranslateService).setTranslation('en', TRANSLATIONS); + TestBed.inject(TranslateService).use('en'); + fixture = TestBed.createComponent(DownloadLibraryComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('entities', ENTITIES); + await fixture.whenStable(); + }); + + afterEach(() => { + document + .querySelectorAll('.cdk-overlay-container') + .forEach((element) => element.remove()); + }); + + function byTestId(testId: string): HTMLElement { + const element = fixture.nativeElement.querySelector( + `[data-test-id="${testId}"]` + ) as HTMLElement | null; + if (!element) { + throw new Error(`Missing element: ${testId}`); + } + return element; + } + + function button(container: ParentNode, label: string): HTMLButtonElement { + const element = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.getAttribute('aria-label') === label + ); + if (!element) { + throw new Error(`Missing button: ${label}`); + } + return element as HTMLButtonElement; + } + + async function click(element: HTMLElement): Promise { + element.click(); + await fixture.whenStable(); + } + + async function clickMenuAction( + card: HTMLElement, + triggerLabel: string, + actionLabel: string + ): Promise { + await click(button(card, triggerLabel)); + await click(button(document, actionLabel)); + } + + it('renders movie, grouped-series, and fallback-episode identities', () => { + const section = byTestId('downloads-library-section'); + expect(section.getAttribute('aria-labelledby')).toBe( + 'downloads-library-heading' + ); + expect( + section.querySelector('#downloads-library-heading')?.textContent + ).toContain('Ready to watch'); + const movieCard = byTestId('download-library-movie-9'); + const seriesCard = byTestId('download-library-series-playlist-a-77'); + const fallbackEpisodeCard = byTestId('download-library-episode-21'); + + expect(movieCard.getAttribute('data-test-id')).toBe( + 'download-library-movie-9' + ); + expect(movieCard.textContent).toContain('Moonrise'); + expect(seriesCard.textContent).toContain('3 episodes'); + expect(seriesCard.textContent).toContain('Seasons 1–2'); + expect(fallbackEpisodeCard.textContent).toContain('S02E04'); + expect(fallbackEpisodeCard.textContent).toContain('Episode'); + }); + + it('keeps ready cards clean while preserving tracked size', () => { + const section = byTestId('downloads-library-section'); + const movieCard = byTestId('download-library-movie-9'); + const seriesCard = byTestId('download-library-series-playlist-a-77'); + const episodeCard = byTestId('download-library-episode-21'); + + expect(section.textContent).not.toContain('Offline'); + expect(movieCard.textContent).not.toContain('Cinema'); + expect(seriesCard.textContent).not.toContain('Living room'); + expect(episodeCard.textContent).not.toContain('Archive'); + expect(movieCard.textContent).toContain('2.4 MB'); + }); + + it('puts the movie source ahead of its overflow commands', async () => { + const card = byTestId('download-library-movie-9'); + + await click(button(card, 'More actions: Moonrise')); + + const header = document.querySelector( + '.download-source-menu-header' + ); + const copy = button(document, 'Copy download URL: Moonrise'); + const remove = button(document, 'Remove from manager: Moonrise'); + expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'Source Cinema' + ); + expect( + header && + header.compareDocumentPosition(copy) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + copy.compareDocumentPosition(remove) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('opens downloaded episodes from the series overflow without a file action', async () => { + const card = byTestId('download-library-series-playlist-a-77'); + const episodeGroups: DownloadSeriesCardViewModel[] = []; + const actions: unknown[] = []; + component.episodesOpened.subscribe((group) => + episodeGroups.push(group) + ); + component.itemAction.subscribe((action) => actions.push(action)); + + await click(button(card, 'More actions: Northwind')); + + const header = document.querySelector( + '.download-source-menu-header' + ); + expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'Source Living room' + ); + await click(button(document, 'Open downloaded episodes: Northwind')); + + expect(episodeGroups).toEqual([SERIES]); + expect(actions).toEqual([]); + }); + + it('omits the named library section when no completed entity is visible', async () => { + fixture.componentRef.setInput('entities', []); + await fixture.whenStable(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-library-section"]' + ) + ).toBeNull(); + }); + + it('uses a singular episode count for a one-file series group', async () => { + fixture.componentRef.setInput('entities', [ + { + ...SERIES, + members: [SERIES_MEMBERS[0]], + representative: SERIES_MEMBERS[0], + }, + ]); + await fixture.whenStable(); + + expect( + byTestId('download-library-series-playlist-a-77').textContent + ).toContain('1 episode'); + }); + + it('opens movie details from artwork and title without playing locally', async () => { + const card = byTestId('download-library-movie-9'); + const opened: DownloadItem[] = []; + const actions: unknown[] = []; + component.openRequested.subscribe((selected) => opened.push(selected)); + component.itemAction.subscribe((action) => actions.push(action)); + + await click(button(card, 'Open details: Moonrise artwork')); + await click(button(card, 'Open details: Moonrise')); + + expect(opened).toEqual([MOVIE, MOVIE]); + expect(actions).toEqual([]); + }); + + it('opens orphaned movie details without source-dependent disabled semantics', async () => { + const card = byTestId('download-library-movie-9'); + const artwork = button(card, 'Open details: Moonrise artwork'); + const title = button(card, 'Open details: Moonrise'); + const opened: DownloadItem[] = []; + component.openRequested.subscribe((item) => opened.push(item)); + + expect(artwork.disabled).toBe(false); + expect(title.disabled).toBe(false); + expect(artwork.getAttribute('aria-disabled')).toBeNull(); + expect(title.getAttribute('aria-disabled')).toBeNull(); + + await click(artwork); + await click(title); + expect(opened).toEqual([MOVIE, MOVIE]); + }); + + it('keeps explicit movie toolbar and menu commands local', async () => { + const card = byTestId('download-library-movie-9'); + const actions: unknown[] = []; + const toolbar = card.querySelector( + '.download-library__actions' + ) as HTMLElement; + component.itemAction.subscribe((action) => actions.push(action)); + + await click(button(toolbar, 'Play: Moonrise')); + await click(button(card, 'Show in folder: Moonrise')); + await clickMenuAction( + card, + 'More actions: Moonrise', + 'Copy download URL: Moonrise' + ); + await clickMenuAction( + card, + 'More actions: Moonrise', + 'Remove from manager: Moonrise' + ); + + expect(actions).toEqual([ + { type: 'play', item: MOVIE }, + { type: 'reveal', item: MOVIE }, + { type: 'copy-url', item: MOVIE }, + { type: 'remove', item: MOVIE }, + ]); + }); + + it('opens grouped-series details from both real navigation buttons', async () => { + const card = byTestId('download-library-series-playlist-a-77'); + const opened: DownloadItem[] = []; + component.openRequested.subscribe((selected) => opened.push(selected)); + + const artwork = button(card, 'Open details: Northwind artwork'); + const title = button(card, 'Open details: Northwind'); + expect(artwork.tagName).toBe('BUTTON'); + expect(title.tagName).toBe('BUTTON'); + + artwork.focus(); + expect(document.activeElement).toBe(artwork); + await click(artwork); + await click(title); + + expect(opened).toEqual([SERIES.representative, SERIES.representative]); + }); + + it('disables pending grouped-series navigation without blocking local episodes', async () => { + fixture.componentRef.setInput( + 'pendingIds', + new Set([SERIES.representative.id]) + ); + await fixture.whenStable(); + const card = byTestId('download-library-series-playlist-a-77'); + const artwork = button(card, 'Open details: Northwind artwork'); + const title = button(card, 'Open details: Northwind'); + const episodes = button(card, 'Open downloaded episodes: Northwind'); + const opened: DownloadItem[] = []; + const episodeGroups: DownloadSeriesCardViewModel[] = []; + component.openRequested.subscribe((item) => opened.push(item)); + component.episodesOpened.subscribe((group) => + episodeGroups.push(group) + ); + + expect(artwork.disabled).toBe(true); + expect(title.disabled).toBe(true); + expect(episodes.disabled).toBe(false); + + artwork.dispatchEvent(new MouseEvent('click', { bubbles: true })); + title.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await fixture.whenStable(); + expect(opened).toEqual([]); + + await click( + button( + byTestId('download-library-movie-9'), + 'Open details: Moonrise artwork' + ) + ); + expect(opened).toEqual([MOVIE]); + + await click(episodes); + expect(episodeGroups).toEqual([SERIES]); + }); + + it('opens orphaned series navigation while keeping episodes local', async () => { + const card = byTestId('download-library-series-playlist-a-77'); + const artwork = button(card, 'Open details: Northwind artwork'); + const title = button(card, 'Open details: Northwind'); + const episodes = button(card, 'Open downloaded episodes: Northwind'); + const opened: DownloadItem[] = []; + component.openRequested.subscribe((item) => opened.push(item)); + + expect(artwork.disabled).toBe(false); + expect(title.disabled).toBe(false); + expect(artwork.getAttribute('aria-disabled')).toBeNull(); + expect(title.getAttribute('aria-disabled')).toBeNull(); + expect(episodes.disabled).toBe(false); + + artwork.focus(); + expect(document.activeElement).toBe(artwork); + + await click(artwork); + await click(title); + expect(opened).toEqual([SERIES.representative, SERIES.representative]); + }); + + it('opens the downloaded episode group from its count control', async () => { + const opened: DownloadSeriesCardViewModel[] = []; + component.episodesOpened.subscribe((group) => opened.push(group)); + + await click( + button( + byTestId('download-library-series-playlist-a-77'), + 'Open downloaded episodes: Northwind' + ) + ); + + expect(opened).toEqual([SERIES]); + }); + + it('keeps an invalid-series episode local without series navigation', async () => { + const card = byTestId('download-library-episode-21'); + const itemActions: unknown[] = []; + const opened: DownloadItem[] = []; + component.itemAction.subscribe((action) => itemActions.push(action)); + component.openRequested.subscribe((selected) => opened.push(selected)); + + expect( + card.querySelector('[data-test-id="download-library-series-open"]') + ).toBeNull(); + const artwork = card.querySelector( + '.download-library__artwork-button' + ) as HTMLButtonElement; + const title = card.querySelector( + '.download-library__title-button' + ) as HTMLButtonElement; + + expect(artwork.getAttribute('aria-label')).toBe('Play: Legacy episode'); + expect(title.getAttribute('aria-label')).toBe('Play: Legacy episode'); + await click(artwork); + await click(title); + + expect(itemActions).toEqual([ + { type: 'play', item: FALLBACK_EPISODE }, + { type: 'play', item: FALLBACK_EPISODE }, + ]); + expect(opened).toEqual([]); + }); + + it('disables every local command while the concrete item is pending', async () => { + fixture.componentRef.setInput('pendingIds', new Set([9, 21])); + await fixture.whenStable(); + + for (const testId of [ + 'download-library-movie-9', + 'download-library-episode-21', + ]) { + const card = byTestId(testId); + expect( + Array.from(card.querySelectorAll('button')).every( + (candidate) => candidate.disabled + ) + ).toBe(true); + } + }); + + it('replaces broken artwork with one semantic placeholder', async () => { + const card = byTestId('download-library-episode-21'); + const image = card.querySelector('img') as HTMLImageElement; + + image.dispatchEvent(new Event('error')); + await fixture.whenStable(); + + expect(card.querySelector('img')).toBeNull(); + const placeholders = card.querySelectorAll( + '[role="img"][aria-label="Artwork unavailable: Legacy episode"]' + ); + expect(placeholders).toHaveLength(1); + }); + + it('uses the shared cover-grid sizing contract', () => { + const styles = readFileSync( + join(__dirname, 'download-library.component.scss'), + 'utf8' + ); + + expect(styles).toContain('var(--cover-grid-min-width, 148px)'); + expect(styles).toContain('var(--cover-gap, 16px)'); + expect(styles).not.toContain( + 'grid-template-columns: repeat(2, minmax(0, 1fr))' + ); + }); + + it('uses native disabled states for ready-card action styling', () => { + const styles = readFileSync( + join(__dirname, 'download-library.component.scss'), + 'utf8' + ); + const disabledRule = + styles.match(/button:disabled\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(styles).toContain('&:hover:not(:disabled) img'); + expect(styles).toContain('&:focus-visible:not(:disabled) img'); + expect(styles).not.toContain("[aria-disabled='true']"); + expect(styles).not.toContain('&--disabled'); + expect(disabledRule).toContain('cursor: default'); + expect(disabledRule).toContain('opacity: 0.48'); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts new file mode 100644 index 000000000..7a175a4cf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -0,0 +1,126 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { DownloadItem } from '@iptvnator/services'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { + DownloadItemAction, + DownloadItemActionType, +} from './download-actions'; +import type { + DownloadLibraryEntity, + DownloadSeriesCardViewModel, +} from './download-library.viewmodel'; +import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component'; + +@Component({ + selector: 'app-download-library', + standalone: true, + imports: [ + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + TranslatePipe, + DownloadSourceMenuHeaderComponent, + ], + templateUrl: './download-library.component.html', + styleUrl: './download-library.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadLibraryComponent { + readonly entities = input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + readonly episodesOpened = output(); + readonly failedArtwork = signal>(new Set()); + + protected libraryTestId(entity: DownloadLibraryEntity): string { + switch (entity.kind) { + case 'movie': + return `download-library-movie-${entity.item.id}`; + case 'episode': + return `download-library-episode-${entity.item.id}`; + case 'series': + return `download-library-series-${entity.representative.playlistId}-${entity.seriesXtreamId}`; + } + } + + protected artworkUrl(entity: DownloadLibraryEntity): string | undefined { + const raw = + entity.kind === 'series' ? entity.posterUrl : entity.item.posterUrl; + return raw?.trim() && !this.failedArtwork().has(entity.key) + ? raw + : undefined; + } + + protected markArtworkFailed( + entity: DownloadLibraryEntity, + event: Event + ): void { + if (this.failedArtwork().has(entity.key)) { + return; + } + this.failedArtwork.update((failed) => { + const next = new Set(failed); + next.add(entity.key); + return next; + }); + (event.target as HTMLImageElement | null)?.removeAttribute('src'); + } + + protected isPending(item: DownloadItem): boolean { + return this.pendingIds().has(item.id); + } + + protected emitAction( + type: DownloadItemActionType, + item: DownloadItem + ): void { + if (!this.isPending(item)) { + this.itemAction.emit({ type, item }); + } + } + + protected openDetails(item: DownloadItem): void { + if (!this.isPending(item)) { + this.openRequested.emit(item); + } + } + + protected openEpisodes(group: DownloadSeriesCardViewModel): void { + this.episodesOpened.emit(group); + } + + protected episodeCountKey(count: number): string { + return count === 1 + ? 'DOWNLOADS.EPISODE_COUNT_ONE' + : 'DOWNLOADS.EPISODE_COUNT_OTHER'; + } + + protected formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1 + ); + const value = bytes / 1024 ** exponent; + const formatted = + value >= 10 || Number.isInteger(value) + ? value.toFixed(0) + : value.toFixed(1); + return `${formatted} ${units[exponent]}`; + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts new file mode 100644 index 000000000..09b7f656b --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.spec.ts @@ -0,0 +1,202 @@ +import type { DownloadItem } from '@iptvnator/services'; +import { + buildDownloadLibrary, + compareDownloadIds, + deriveStandardizedSeriesTitle, + isUsableDownloadOrderNumber, + type DownloadLibraryRow, + type DownloadSeriesCardViewModel, +} from './download-library.viewmodel'; + +const CREATED_AT = '2026-01-01T00:00:00Z'; +const MALFORMED_ID_ORDER = [ + 'nan', + 'negative-infinity', + 'negative', + 'zero', + 'fractional', + 'unsafe', + 'positive-infinity', +] as const; +const ID_CASES = [ + { id: 7, title: 'valid-7' }, + { id: Number.NaN, title: 'nan' }, + { id: Number.POSITIVE_INFINITY, title: 'positive-infinity' }, + { id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' }, + { id: -3, title: 'negative' }, + { id: 0, title: 'zero' }, + { id: 42.5, title: 'fractional' }, + { id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' }, + { id: 2, title: 'valid-2' }, +] as const; + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1, + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + title: `Episode ${id}`, + url: 'https://example.test/episode', + status: 'completed', + createdAt: CREATED_AT, + ...overrides, + }; +} + +function row(item: DownloadItem): DownloadLibraryRow { + return { + item, + episodeLabel: '', + sourceName: 'Alpha Source', + }; +} + +function buildSeries( + items: readonly DownloadItem[] +): DownloadSeriesCardViewModel { + const [entity] = buildDownloadLibrary(items.map(row)); + expect(entity.kind).toBe('series'); + if (entity.kind !== 'series') { + throw new Error('Expected a grouped series entity'); + } + return entity; +} + +function idEpisodes( + cases: readonly (typeof ID_CASES)[number][] +): DownloadItem[] { + return cases.map(({ id, title }) => download(id, { title })); +} + +describe('download library view model helpers', () => { + it('does not derive a series title from a non-episode item', () => { + const movie = download(1, { + contentType: 'vod', + title: 'Movie - S01E02 - Not an episode', + }); + + expect(deriveStandardizedSeriesTitle(movie)).toBeUndefined(); + }); + + it('derives the standardized prefix from an episode title', () => { + const episode = download(1, { + title: 'Canonical Show - S01E02 - The episode', + }); + + expect(deriveStandardizedSeriesTitle(episode)).toBe('Canonical Show'); + }); + + it.each([ + [0, true], + [1, true], + [Number.MAX_SAFE_INTEGER, true], + [1.5, false], + [Number.MAX_SAFE_INTEGER + 1, false], + [Number.NaN, false], + [Number.POSITIVE_INFINITY, false], + [-1, false], + ])('classifies %p as usable=%s', (value, expected) => { + expect(isUsableDownloadOrderNumber(value)).toBe(expected); + }); + + it('returns finite results for duplicate and zero malformed ids', () => { + expect(compareDownloadIds(3, 3)).toBe(0); + expect(compareDownloadIds(Number.NaN, Number.NaN)).toBe(0); + expect(compareDownloadIds(0, Number.NaN)).toBe(1); + }); + + it('keeps a fractional series id as an individual episode card', () => { + const entities = buildDownloadLibrary([ + row(download(1, { seriesXtreamId: 42.5 })), + ]); + + expect(entities).toEqual([ + expect.objectContaining({ + kind: 'episode', + key: 'episode:1', + }), + ]); + }); + + it('orders member ids with valid ascending before the documented malformed fallback', () => { + const expected = ['valid-2', 'valid-7', ...MALFORMED_ID_ORDER]; + const forward = buildSeries(idEpisodes(ID_CASES)); + const reversed = buildSeries(idEpisodes([...ID_CASES].reverse())); + + expect(forward.members.map(({ title }) => title)).toEqual(expected); + expect(reversed.members.map(({ title }) => title)).toEqual(expected); + }); + + it('chooses the greatest valid id as representative before malformed ids', () => { + const forward = buildSeries(idEpisodes(ID_CASES)); + const reversed = buildSeries(idEpisodes([...ID_CASES].reverse())); + + expect(forward.representative.title).toBe('valid-7'); + expect(reversed.representative.title).toBe('valid-7'); + }); + + it('chooses a malformed representative by canonical fallback independent of input order', () => { + const malformed = ID_CASES.filter( + ({ title }) => !title.startsWith('valid') + ); + const forward = buildSeries(idEpisodes(malformed)); + const reversed = buildSeries(idEpisodes([...malformed].reverse())); + + expect(forward.representative.title).toBe('nan'); + expect(reversed.representative.title).toBe('nan'); + }); + + it('sorts fractional and unsafe season or episode values last', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const series = buildSeries([ + download(1, { title: 'valid', seasonNumber: 1, episodeNumber: 2 }), + download(2, { + title: 'fractional-season', + seasonNumber: 1.5, + episodeNumber: 1, + }), + download(3, { + title: 'unsafe-season', + seasonNumber: unsafe, + episodeNumber: 1, + }), + download(4, { + title: 'fractional-episode', + seasonNumber: 1, + episodeNumber: 1.5, + }), + download(5, { + title: 'unsafe-episode', + seasonNumber: 1, + episodeNumber: unsafe, + }), + ]); + + expect(series.members.map(({ title }) => title)).toEqual([ + 'valid', + 'fractional-episode', + 'unsafe-episode', + 'fractional-season', + 'unsafe-season', + ]); + }); + + it('ignores fractional and unsafe seasons when deriving the range', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const series = buildSeries([ + download(1, { seasonNumber: 2 }), + download(2, { seasonNumber: 1.5 }), + download(3, { seasonNumber: unsafe }), + ]); + + expect(series.firstSeason).toBe(2); + expect(series.lastSeason).toBe(2); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts new file mode 100644 index 000000000..f21b50352 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts @@ -0,0 +1,261 @@ +import type { DownloadItem } from '@iptvnator/services'; + +export interface DownloadLibraryRow { + readonly item: DownloadItem; + readonly episodeLabel: string; + readonly sourceName: string; +} + +interface DownloadLibraryEntityBase { + readonly key: string; + readonly newestTimestamp: number; + readonly sourceName: string; + readonly trackedBytes: number; +} + +interface DownloadItemLibraryEntityBase extends DownloadLibraryEntityBase { + readonly item: DownloadItem; +} + +export interface DownloadMovieCardViewModel extends DownloadItemLibraryEntityBase { + readonly kind: 'movie'; +} + +export interface DownloadEpisodeCardViewModel extends DownloadItemLibraryEntityBase { + readonly kind: 'episode'; + readonly episodeLabel: string; +} + +export interface DownloadSeriesCardViewModel extends DownloadLibraryEntityBase { + readonly kind: 'series'; + readonly representative: DownloadItem; + readonly members: readonly DownloadItem[]; + readonly seriesXtreamId: number; + readonly title: string; + readonly posterUrl?: string; + readonly firstSeason?: number; + readonly lastSeason?: number; +} + +export type DownloadLibraryEntity = + | DownloadMovieCardViewModel + | DownloadEpisodeCardViewModel + | DownloadSeriesCardViewModel; + +const STANDARD_EPISODE_TITLE = /^(.+?) - S\d{2}E\d{2} - /; +const MALFORMED_DOWNLOAD_ID_CLASS = { + NAN: '0:nan', + NEGATIVE_INFINITY: '1:negative-infinity', + NEGATIVE: '2:negative', + ZERO: '3:zero', + FRACTIONAL: '4:fractional', + UNSAFE: '5:unsafe', + POSITIVE_INFINITY: '6:positive-infinity', +} as const; + +export function normalizedDownloadTimestamp(value: string | undefined): number { + const timestamp = Date.parse(value ?? ''); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function isUsableDownloadOrderNumber( + value: number | undefined +): value is number { + return Number.isSafeInteger(value) && value !== undefined && value >= 0; +} + +function malformedDownloadIdKey(value: number): string { + if (Number.isNaN(value)) { + return MALFORMED_DOWNLOAD_ID_CLASS.NAN; + } + if (value === Number.NEGATIVE_INFINITY) { + return MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE_INFINITY; + } + if (value === Number.POSITIVE_INFINITY) { + return MALFORMED_DOWNLOAD_ID_CLASS.POSITIVE_INFINITY; + } + if (value < 0) { + return `${MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE}:${String(value)}`; + } + if (value === 0) { + return MALFORMED_DOWNLOAD_ID_CLASS.ZERO; + } + if (!Number.isInteger(value)) { + return `${MALFORMED_DOWNLOAD_ID_CLASS.FRACTIONAL}:${String(value)}`; + } + return `${MALFORMED_DOWNLOAD_ID_CLASS.UNSAFE}:${String(value)}`; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function compareDownloadIds( + left: number, + right: number, + descending = false +): number { + const leftValid = Number.isSafeInteger(left) && left > 0; + const rightValid = Number.isSafeInteger(right) && right > 0; + if (leftValid && rightValid) { + const first = descending ? right : left; + const second = descending ? left : right; + return first < second ? -1 : first > second ? 1 : 0; + } + if (leftValid) { + return -1; + } + if (rightValid) { + return 1; + } + return compareText( + malformedDownloadIdKey(left), + malformedDownloadIdKey(right) + ); +} + +export function deriveStandardizedSeriesTitle( + item: DownloadItem +): string | undefined { + if (item.contentType !== 'episode') { + return undefined; + } + return STANDARD_EPISODE_TITLE.exec(item.title)?.[1]; +} + +function compareOrderNumber( + left: number | undefined, + right: number | undefined +): number { + const leftUsable = isUsableDownloadOrderNumber(left); + const rightUsable = isUsableDownloadOrderNumber(right); + if (leftUsable && rightUsable) { + return left - right; + } + if (leftUsable) { + return -1; + } + return rightUsable ? 1 : 0; +} + +function compareMembers(left: DownloadItem, right: DownloadItem): number { + return ( + compareOrderNumber(left.seasonNumber, right.seasonNumber) || + compareOrderNumber(left.episodeNumber, right.episodeNumber) || + normalizedDownloadTimestamp(left.createdAt) - + normalizedDownloadTimestamp(right.createdAt) || + compareDownloadIds(left.id, right.id) + ); +} + +function compareNewest(left: DownloadItem, right: DownloadItem): number { + return ( + normalizedDownloadTimestamp(right.createdAt) - + normalizedDownloadTimestamp(left.createdAt) || + compareDownloadIds(left.id, right.id, true) + ); +} + +function isValidSeriesId(value: number | undefined): value is number { + return Number.isSafeInteger(value) && (value ?? 0) > 0; +} + +function createSeriesEntity( + rows: readonly DownloadLibraryRow[], + seriesXtreamId: number +): DownloadSeriesCardViewModel { + const members = rows.map(({ item }) => item).sort(compareMembers); + const newestFirst = [...members].sort(compareNewest); + const representative = newestFirst[0]; + const posterUrl = newestFirst.find( + (item) => (item.posterUrl?.trim().length ?? 0) > 0 + )?.posterUrl; + const standardizedTitle = members + .map(deriveStandardizedSeriesTitle) + .find((title) => title !== undefined); + const legacyTitle = + members.find((item) => item.title.trim().length > 0)?.title ?? ''; + const seasons = members + .map(({ seasonNumber }) => seasonNumber) + .filter(isUsableDownloadOrderNumber); + + return { + kind: 'series', + key: `series:${representative.playlistId}:${seriesXtreamId}`, + representative, + members, + seriesXtreamId, + title: standardizedTitle ?? legacyTitle, + ...(posterUrl === undefined ? {} : { posterUrl }), + newestTimestamp: normalizedDownloadTimestamp(representative.createdAt), + sourceName: rows[0].sourceName, + trackedBytes: members.reduce( + (total, item) => total + (item.bytesDownloaded ?? 0), + 0 + ), + ...(seasons.length === 0 + ? {} + : { + firstSeason: Math.min(...seasons), + lastSeason: Math.max(...seasons), + }), + }; +} + +export function buildDownloadLibrary( + rows: readonly DownloadLibraryRow[] +): DownloadLibraryEntity[] { + const entities: DownloadLibraryEntity[] = []; + const groups = new Map< + string, + { + readonly seriesXtreamId: number; + readonly rows: DownloadLibraryRow[]; + } + >(); + + for (const row of rows) { + const { item } = row; + if (item.contentType === 'vod') { + entities.push({ + kind: 'movie', + key: `movie:${item.id}`, + item, + newestTimestamp: normalizedDownloadTimestamp(item.createdAt), + sourceName: row.sourceName, + trackedBytes: item.bytesDownloaded ?? 0, + }); + } else if (isValidSeriesId(item.seriesXtreamId)) { + const key = `${item.playlistId}:${item.seriesXtreamId}`; + const group = groups.get(key); + if (group) { + group.rows.push(row); + } else { + groups.set(key, { + seriesXtreamId: item.seriesXtreamId, + rows: [row], + }); + } + } else { + entities.push({ + kind: 'episode', + key: `episode:${item.id}`, + item, + newestTimestamp: normalizedDownloadTimestamp(item.createdAt), + sourceName: row.sourceName, + trackedBytes: item.bytesDownloaded ?? 0, + episodeLabel: row.episodeLabel, + }); + } + } + + for (const { rows: members, seriesXtreamId } of groups.values()) { + entities.push(createSeriesEntity(members, seriesXtreamId)); + } + + return entities.sort( + (left, right) => + right.newestTimestamp - left.newestTimestamp || + (left.key < right.key ? -1 : left.key > right.key ? 1 : 0) + ); +} diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts new file mode 100644 index 000000000..20f988ac4 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -0,0 +1,210 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { type DownloadItem, DownloadsService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import type { + DownloadActionResult, + DownloadItemAction, +} from './download-actions'; + +interface DownloadOperationResult { + readonly success: boolean; + readonly error?: string; +} + +@Injectable() +export class DownloadManagerActionsService { + private readonly downloads = inject(DownloadsService); + private readonly dialogs = inject(DialogService); + private readonly translate = inject(TranslateService); + private readonly snackBar = inject(MatSnackBar); + + readonly pendingIds = signal>(new Set()); + readonly isClearing = signal(false); + + async run(action: DownloadItemAction): Promise { + const { item, type } = action; + if (this.pendingIds().has(item.id)) { + return 'ignored'; + } + if (type === 'remove') { + this.confirmRemove(item); + return 'ignored'; + } + if (type === 'copy-url') { + return this.copyUrl(item); + } + if (type === 'play' || type === 'reveal') { + return this.runFileAction(type, item); + } + const operations = { + cancel: () => this.downloads.cancelDownload(item.id), + pause: () => this.downloads.pauseDownload(item.id), + redownload: () => this.downloads.redownloadMissing(item.id), + resume: () => this.downloads.resumeDownload(item.id), + retry: () => this.downloads.retryDownload(item.id), + }; + return this.withPending(item.id, operations[type]); + } + + clearFinished(scopePlaylistId?: string): void { + this.dialogs.openConfirmDialog({ + title: this.translate.instant( + 'DOWNLOADS.CLEAR_FINISHED_DIALOG.TITLE' + ), + message: this.translate.instant( + 'DOWNLOADS.CLEAR_FINISHED_DIALOG.MESSAGE' + ), + confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_FINISHED'), + onConfirm: () => this.performClearFinished(scopePlaylistId), + }); + } + + showActionError(error?: string): void { + const base = this.translate.instant('DOWNLOADS.ACTION_FAILED'); + this.snackBar.open(error ? `${base}: ${error}` : base, undefined, { + duration: 4000, + horizontalPosition: 'start', + }); + } + + showMessage(key: string, duration: number): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration, + horizontalPosition: 'start', + }); + } + + private confirmRemove(item: DownloadItem): void { + const dialogKey = + item.status === 'completed' + ? 'DOWNLOADS.REMOVE_COMPLETED_DIALOG' + : 'DOWNLOADS.REMOVE_PARTIAL_DIALOG'; + this.dialogs.openConfirmDialog({ + title: this.translate.instant(`${dialogKey}.TITLE`), + message: this.translate.instant(`${dialogKey}.MESSAGE`), + confirmLabel: this.translate.instant( + 'DOWNLOADS.REMOVE_FROM_MANAGER' + ), + onConfirm: () => + this.withPending(item.id, () => + this.downloads.removeDownload(item.id) + ), + }); + } + + private async performClearFinished( + scopePlaylistId?: string + ): Promise { + if (this.isClearing()) { + return; + } + this.isClearing.set(true); + try { + const result = await this.downloads.clearCompleted(scopePlaylistId); + if (!result.success) { + this.showActionError(); + } + } catch (error) { + this.showActionError( + error instanceof Error ? error.message : String(error) + ); + } finally { + this.isClearing.set(false); + } + } + + private async runFileAction( + type: 'play' | 'reveal', + item: DownloadItem + ): Promise { + const filePath = item.filePath; + if (!filePath) { + this.showMessage('DOWNLOADS.FILE_NOT_FOUND', 3000); + return 'file-missing'; + } + return this.withPending( + item.id, + () => + type === 'play' + ? this.downloads.playDownload(filePath) + : this.downloads.revealFile(filePath), + async (error) => { + if (error === 'File not found') { + await this.downloads.loadDownloads(); + } + this.showFileActionError(error); + }, + (error) => (error === 'File not found' ? 'file-missing' : 'failed') + ); + } + + private async copyUrl(item: DownloadItem): Promise { + return this.withPending( + item.id, + async () => { + try { + await navigator.clipboard.writeText(item.url); + this.showMessage('DOWNLOADS.URL_COPIED', 2000); + return { success: true }; + } catch { + return { success: false }; + } + }, + () => this.showMessage('DOWNLOADS.URL_COPY_FAILED', 3000) + ); + } + + private async withPending( + itemId: number, + operation: () => Promise, + onFailure: (error?: string) => void | Promise = (error) => + this.showActionError(error), + failureResult: (error?: string) => DownloadActionResult = () => 'failed' + ): Promise { + if (this.pendingIds().has(itemId)) { + return 'ignored'; + } + this.pendingIds.update((ids) => new Set(ids).add(itemId)); + try { + let failure: string | undefined; + let failed = false; + try { + const result = await operation(); + failed = !result.success; + failure = result.error; + } catch (error) { + failed = true; + failure = + error instanceof Error ? error.message : String(error); + } + if (failed) { + try { + await onFailure(failure); + } catch (error) { + this.showActionError( + error instanceof Error ? error.message : String(error) + ); + } + return failureResult(failure); + } + return 'success'; + } finally { + this.pendingIds.update((ids) => { + const next = new Set(ids); + next.delete(itemId); + return next; + }); + } + } + + private showFileActionError(error?: string): void { + this.showMessage( + error === 'File not found' + ? 'DOWNLOADS.FILE_NOT_FOUND' + : 'DOWNLOADS.FILE_ACTION_ERROR', + 3000 + ); + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts new file mode 100644 index 000000000..f324798c7 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -0,0 +1,1080 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { + buildDownloadManagerViewModel, + normalizeDownloadFilter, + type DownloadEpisodeCardViewModel, + type DownloadLibraryEntity, + type DownloadManagerViewModel, + type DownloadMovieCardViewModel, + type DownloadSeriesCardViewModel, +} from './download-manager.viewmodel'; + +type PlaylistSummary = Pick; +type ExpectedDownloadLibraryEntity = + | DownloadMovieCardViewModel + | DownloadEpisodeCardViewModel + | DownloadSeriesCardViewModel; + +const PLAYLISTS: readonly PlaylistSummary[] = [ + { _id: 'playlist-a', title: 'Alpha Source' }, + { _id: 'playlist-b', title: 'Beta Source' }, +]; + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: id * 10, + contentType: 'vod', + title: `Download ${id}`, + url: `https://example.test/${id}`, + status: 'completed', + createdAt: `2026-01-${String(id).padStart(2, '0')}T00:00:00Z`, + ...overrides, + }; +} + +function build( + downloads: readonly DownloadItem[], + options: { + playlists?: readonly PlaylistSummary[]; + scopePlaylistId?: string; + filter?: string; + searchTerm?: string; + } = {} +): DownloadManagerViewModel { + return buildDownloadManagerViewModel({ + downloads, + playlists: options.playlists ?? PLAYLISTS, + scopePlaylistId: options.scopePlaylistId, + filter: options.filter ?? 'all', + searchTerm: options.searchTerm, + }); +} + +function rowIds( + rows: + | DownloadManagerViewModel['active'] + | DownloadManagerViewModel['attention'] +): number[] { + return rows.map(({ item }) => item.id); +} + +function libraryItemIds(entities: readonly DownloadLibraryEntity[]): number[] { + const typedEntities: readonly ExpectedDownloadLibraryEntity[] = entities; + return typedEntities.flatMap((entity) => + entity.kind === 'series' + ? entity.members.map(({ id }) => id) + : [entity.item.id] + ); +} + +describe('normalizeDownloadFilter', () => { + it.each(['all', 'movie', 'series', 'in-progress'] as const)( + 'keeps the known %s filter', + (filter) => { + expect(normalizeDownloadFilter(filter)).toBe(filter); + } + ); + + it.each([null, undefined, '', 'unknown', 'MOVIE'])( + 'normalizes %p to all', + (filter) => { + expect(normalizeDownloadFilter(filter)).toBe('all'); + } + ); +}); + +describe('buildDownloadManagerViewModel', () => { + it('supports global and playlist-scoped views with resolved source labels', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { + playlistId: 'playlist-b', + status: 'failed', + }), + download(3, { + playlistId: 'missing-playlist', + status: 'completed', + }), + ]; + + const global = build(downloads); + const scoped = build(downloads, { scopePlaylistId: 'playlist-a' }); + + expect(global.scopedItems.map(({ id }) => id)).toEqual([1, 2, 3]); + expect(global.active[0].sourceName).toBe('Alpha Source'); + expect(global.attention[0].sourceName).toBe('Beta Source'); + expect(global.library[0].sourceName).toBe(''); + expect(scoped.scopedItems.map(({ id }) => id)).toEqual([1]); + expect(rowIds(scoped.active)).toEqual([1]); + expect(scoped.attention).toEqual([]); + expect(scoped.library).toEqual([]); + }); + + it('applies all four display filters across active, attention, and library partitions', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { + contentType: 'episode', + status: 'failed', + seasonNumber: 1, + episodeNumber: 2, + }), + download(3), + download(4, { + contentType: 'episode', + seriesXtreamId: 44, + seasonNumber: 1, + episodeNumber: 1, + }), + ]; + + const all = build(downloads); + const movies = build(downloads, { filter: 'movie' }); + const series = build(downloads, { filter: 'series' }); + const inProgress = build(downloads, { filter: 'in-progress' }); + + expect(rowIds(all.active)).toEqual([1]); + expect(rowIds(all.attention)).toEqual([2]); + expect(all.library.map(({ kind }) => kind)).toEqual([ + 'series', + 'movie', + ]); + + expect(rowIds(movies.active)).toEqual([1]); + expect(movies.attention).toEqual([]); + expect(movies.library.map(({ kind }) => kind)).toEqual(['movie']); + + expect(series.active).toEqual([]); + expect(rowIds(series.attention)).toEqual([2]); + expect(series.library.map(({ kind }) => kind)).toEqual(['series']); + + expect(rowIds(inProgress.active)).toEqual([1]); + expect(inProgress.attention).toEqual([]); + expect(inProgress.library).toEqual([]); + }); + + it('keeps queued, downloading, and paused rows in the in-progress filter only', () => { + const result = build( + [ + download(1, { status: 'queued' }), + download(2, { status: 'downloading' }), + download(3, { status: 'paused' }), + download(4, { status: 'failed' }), + download(5, { status: 'completed' }), + ], + { filter: 'in-progress' } + ); + + expect(rowIds(result.active)).toEqual([1, 2, 3]); + expect(result.attention).toEqual([]); + expect(result.library).toEqual([]); + }); + + it('keeps scoped counts stable when search hides every displayed item', () => { + const downloads = [ + download(1, { + status: 'queued', + bytesDownloaded: 1, + }), + download(2, { + contentType: 'episode', + status: 'downloading', + bytesDownloaded: 2, + }), + download(3, { + status: 'paused', + bytesDownloaded: 3, + }), + download(4, { + contentType: 'episode', + status: 'failed', + bytesDownloaded: 4, + }), + download(5, { + status: 'canceled', + bytesDownloaded: 5, + }), + download(6, { bytesDownloaded: 6 }), + download(7, { + contentType: 'episode', + seriesXtreamId: 70, + bytesDownloaded: 7, + }), + download(8, { + contentType: 'episode', + seriesXtreamId: 70, + bytesDownloaded: 8, + }), + download(9, { + contentType: 'episode', + bytesDownloaded: 9, + }), + download(10, { + playlistId: 'playlist-b', + status: 'queued', + bytesDownloaded: 100, + }), + ]; + + const visible = build(downloads, { scopePlaylistId: 'playlist-a' }); + const hidden = build(downloads, { + scopePlaylistId: 'playlist-a', + searchTerm: 'no result can match this', + }); + + expect(visible.counts).toEqual({ + all: 8, + movie: 4, + series: 4, + inProgress: 3, + }); + expect(hidden.counts).toEqual(visible.counts); + expect(hidden.scopedItems).toHaveLength(9); + expect(hidden.active).toEqual([]); + expect(hidden.attention).toEqual([]); + expect(hidden.library).toEqual([]); + expect(hidden.activeCount).toBe(2); + expect(hidden.trackedBytes).toBe(45); + expect(hidden.hasClearable).toBe(true); + }); + + it('keeps counts stable under every display filter', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { + contentType: 'episode', + status: 'paused', + }), + download(3, { status: 'failed' }), + download(4), + download(5, { + contentType: 'episode', + seriesXtreamId: 50, + }), + download(6, { + contentType: 'episode', + seriesXtreamId: 50, + }), + ]; + const expected = { + all: 5, + movie: 3, + series: 2, + inProgress: 2, + }; + + for (const filter of [ + 'all', + 'movie', + 'series', + 'in-progress', + ] as const) { + expect(build(downloads, { filter }).counts).toEqual(expected); + } + }); + + it('searches stored title, derived series title, source, episode label, and error message', () => { + const downloads = [ + download(1, { + title: 'Solar Feature', + status: 'queued', + }), + download(2, { + contentType: 'episode', + title: 'Archive Show - S01E02 - The Pilot', + seasonNumber: 1, + episodeNumber: 2, + status: 'paused', + }), + download(3, { + playlistId: 'playlist-b', + title: 'Unrelated', + status: 'failed', + }), + download(4, { + contentType: 'episode', + title: 'A legacy chapter', + seasonNumber: 7, + episodeNumber: 8, + }), + download(5, { + title: 'Another unrelated item', + status: 'canceled', + errorMessage: 'Checksum mismatch', + }), + ]; + + expect( + libraryItemIds( + build(downloads, { + searchTerm: ' A LEGACY CHAPTER ', + }).library + ) + ).toEqual([4]); + expect( + rowIds( + build(downloads, { + searchTerm: 'archive show', + }).active + ) + ).toEqual([2]); + expect( + rowIds( + build(downloads, { + searchTerm: 'beta source', + }).attention + ) + ).toEqual([3]); + expect( + libraryItemIds( + build(downloads, { + searchTerm: 's07e08', + }).library + ) + ).toEqual([4]); + expect( + rowIds( + build(downloads, { + searchTerm: 'checksum mismatch', + }).attention + ) + ).toEqual([5]); + + const episode = build(downloads).active.find( + ({ item }) => item.id === 2 + ); + expect(episode).toMatchObject({ + episodeLabel: 'S01E02', + seriesTitle: 'Archive Show', + }); + }); + + it('matches a stored movie title when no other search field contains the query', () => { + const result = build( + [ + download(1, { + title: 'The Stored Needle', + status: 'queued', + }), + ], + { searchTerm: 'stored needle' } + ); + + expect(rowIds(result.active)).toEqual([1]); + expect(result.active[0].item.title).toBe('The Stored Needle'); + expect([ + result.active[0].seriesTitle, + result.active[0].sourceName, + result.active[0].episodeLabel, + result.active[0].item.errorMessage ?? '', + ]).toEqual(['', 'Alpha Source', '', '']); + }); + + it('derives labels only from usable episode numbers and preserves legacy titles', () => { + const downloads = [ + download(1, { + contentType: 'episode', + title: 'Modern - S03E09 - A title', + seasonNumber: 3, + episodeNumber: 9, + status: 'queued', + }), + download(2, { + contentType: 'episode', + title: 'Legacy episode title', + seasonNumber: Number.NaN, + episodeNumber: 2, + status: 'queued', + }), + download(3, { + contentType: 'episode', + title: 'Negative episode', + seasonNumber: 1, + episodeNumber: -1, + status: 'queued', + }), + download(4, { + title: 'Movie title', + seasonNumber: 1, + episodeNumber: 2, + status: 'queued', + }), + ]; + + expect( + build(downloads).active.map( + ({ item, episodeLabel, seriesTitle }) => ({ + id: item.id, + episodeLabel, + seriesTitle, + }) + ) + ).toEqual([ + { + id: 1, + episodeLabel: 'S03E09', + seriesTitle: 'Modern', + }, + { + id: 2, + episodeLabel: '', + seriesTitle: 'Legacy episode title', + }, + { + id: 3, + episodeLabel: '', + seriesTitle: 'Negative episode', + }, + { id: 4, episodeLabel: '', seriesTitle: '' }, + ]); + }); + + it('leaves episode labels blank for fractional and unsafe season or episode numbers', () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + const result = build([ + download(1, { + contentType: 'episode', + seasonNumber: 1.5, + episodeNumber: 2, + status: 'queued', + }), + download(2, { + contentType: 'episode', + seasonNumber: unsafe, + episodeNumber: 2, + status: 'queued', + }), + download(3, { + contentType: 'episode', + seasonNumber: 1, + episodeNumber: 2.5, + status: 'queued', + }), + download(4, { + contentType: 'episode', + seasonNumber: 1, + episodeNumber: unsafe, + status: 'queued', + }), + ]); + + expect(result.active.map(({ episodeLabel }) => episodeLabel)).toEqual([ + '', + '', + '', + '', + ]); + }); + + it('partitions every supported status without leaking items between sections', () => { + const downloads = [ + download(1, { status: 'queued' }), + download(2, { status: 'downloading' }), + download(3, { status: 'paused' }), + download(4, { status: 'failed' }), + download(5, { status: 'canceled' }), + download(6, { status: 'completed' }), + ]; + + const result = build(downloads); + + expect(rowIds(result.active)).toEqual([1, 2, 3]); + expect(rowIds(result.attention)).toEqual([4, 5]); + expect(libraryItemIds(result.library)).toEqual([6]); + }); + + it('moves missing completed movies to attention instead of the ready library', () => { + const result = build([ + download(1, { fileAvailability: 'available' }), + download(2, { fileAvailability: 'missing' }), + ]); + + expect(rowIds(result.attention)).toEqual([2]); + expect(result.attention[0].attentionReason).toBe('file-missing'); + expect(libraryItemIds(result.library)).toEqual([1]); + }); + + it('keeps available series members grouped and exposes each missing episode', () => { + const seriesEpisode = ( + id: number, + fileAvailability: DownloadItem['fileAvailability'] + ) => + download(id, { + contentType: 'episode', + episodeNumber: id, + fileAvailability, + seasonNumber: 1, + seriesXtreamId: 70, + }); + const result = build([ + seriesEpisode(1, 'available'), + seriesEpisode(2, 'missing'), + seriesEpisode(3, 'available'), + ]); + + expect(rowIds(result.attention)).toEqual([2]); + expect(result.attention[0].attentionReason).toBe('file-missing'); + expect(result.library).toHaveLength(1); + expect(result.library[0].kind).toBe('series'); + if (result.library[0].kind === 'series') { + expect(result.library[0].members.map(({ id }) => id)).toEqual([ + 1, 3, + ]); + } + expect(result.counts).toEqual({ + all: 2, + movie: 0, + series: 2, + inProgress: 0, + }); + }); + + it('keeps a fully missing series out of Ready to watch', () => { + const result = build([ + download(1, { + contentType: 'episode', + fileAvailability: 'missing', + seriesXtreamId: 70, + }), + download(2, { + contentType: 'episode', + fileAvailability: 'missing', + seriesXtreamId: 70, + }), + ]); + + expect(result.library).toEqual([]); + expect(rowIds(result.attention)).toEqual([1, 2]); + expect( + result.attention.map(({ attentionReason }) => attentionReason) + ).toEqual(['file-missing', 'file-missing']); + }); + + it('filters and searches missing rows without exposing source metadata in the library', () => { + const downloads = [ + download(1, { + fileAvailability: 'missing', + playlistId: 'playlist-b', + }), + download(2, { + contentType: 'episode', + fileAvailability: 'missing', + playlistId: 'playlist-b', + }), + ]; + + expect( + rowIds( + build(downloads, { + filter: 'movie', + searchTerm: 'beta source', + }).attention + ) + ).toEqual([1]); + expect( + rowIds( + build(downloads, { + filter: 'series', + searchTerm: 'beta source', + }).attention + ) + ).toEqual([2]); + expect(build(downloads).counts).toEqual({ + all: 2, + movie: 1, + series: 1, + inProgress: 0, + }); + }); + + it('does not mutate missing-file inputs while partitioning them', () => { + const available = Object.freeze( + download(1, { fileAvailability: 'available' }) + ); + const missing = Object.freeze( + download(2, { fileAvailability: 'missing' }) + ); + const downloads = Object.freeze([available, missing]); + + expect(() => build(downloads)).not.toThrow(); + expect(downloads).toEqual([available, missing]); + expect(missing).not.toHaveProperty('attentionReason'); + }); + + it('excludes paused rows from activeCount independently of filter and search', () => { + const result = build( + [ + download(1, { status: 'queued' }), + download(2, { status: 'downloading' }), + download(3, { status: 'paused' }), + download(4, { + playlistId: 'playlist-b', + status: 'downloading', + }), + ], + { + scopePlaylistId: 'playlist-a', + filter: 'movie', + searchTerm: 'nothing', + } + ); + + expect(result.activeCount).toBe(2); + expect(result.active).toEqual([]); + }); + + it('sums scoped tracked bytes and reports whether a row can be cleared', () => { + const clearable = build( + [ + download(1, { + status: 'queued', + bytesDownloaded: undefined, + }), + download(2, { + status: 'completed', + bytesDownloaded: 25, + }), + download(3, { + playlistId: 'playlist-b', + status: 'failed', + bytesDownloaded: 100, + }), + ], + { scopePlaylistId: 'playlist-a' } + ); + const activeOnly = build([ + download(4, { status: 'paused', bytesDownloaded: 4 }), + ]); + + expect(clearable.trackedBytes).toBe(25); + expect(clearable.hasClearable).toBe(true); + expect(activeOnly.trackedBytes).toBe(4); + expect(activeOnly.hasClearable).toBe(false); + }); + + it.each(['failed', 'canceled'] as const)( + 'makes a %s-only view clearable', + (status) => { + const result = build([download(1, { status })]); + + expect(result.hasClearable).toBe(true); + } + ); + + it('sorts active and attention rows by normalized timestamp then numeric id', () => { + const downloads = [ + download(9, { status: 'queued', createdAt: undefined }), + download(7, { + status: 'downloading', + createdAt: '2026-01-01T00:00:00Z', + }), + download(3, { + status: 'paused', + createdAt: '2026-01-01T00:00:00Z', + }), + download(10, { + status: 'queued', + createdAt: '2025-01-01T00:00:00Z', + }), + download(2, { + status: 'queued', + createdAt: 'invalid', + }), + download(15, { + status: 'failed', + createdAt: '2026-02-01T00:00:00Z', + }), + download(12, { + status: 'canceled', + createdAt: '2026-02-01T00:00:00Z', + }), + download(11, { + status: 'failed', + createdAt: undefined, + }), + ]; + + const result = build(downloads); + + expect(rowIds(result.active)).toEqual([2, 9, 10, 3, 7]); + expect(rowIds(result.attention)).toEqual([11, 12, 15]); + }); + + it('totally orders valid and malformed queue ids independent of input order', () => { + const createdAt = '2026-01-01T00:00:00Z'; + const cases = [ + { id: 7, title: 'valid-7' }, + { id: Number.NaN, title: 'nan' }, + { id: Number.POSITIVE_INFINITY, title: 'positive-infinity' }, + { id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' }, + { id: -3, title: 'negative' }, + { id: 0, title: 'zero' }, + { id: 42.5, title: 'fractional' }, + { id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' }, + { id: 2, title: 'valid-2' }, + ]; + const expected = [ + 'valid-2', + 'valid-7', + 'nan', + 'negative-infinity', + 'negative', + 'zero', + 'fractional', + 'unsafe', + 'positive-infinity', + ]; + const titlesFor = (input: typeof cases) => + build( + input.map(({ id, title }) => + download(id, { title, status: 'queued', createdAt }) + ) + ).active.map(({ item }) => item.title); + + expect(titlesFor(cases)).toEqual(expected); + expect(titlesFor([...cases].reverse())).toEqual(expected); + }); + + it('sorts completed library entities by newest timestamp then key', () => { + const result = build([ + download(3, { createdAt: '2025-01-01T00:00:00Z' }), + download(20, { createdAt: '2025-01-01T00:00:00Z' }), + download(4, { createdAt: 'invalid' }), + download(5, { createdAt: '2026-01-01T00:00:00Z' }), + ]); + + expect(result.library.map(({ key }) => key)).toEqual([ + 'movie:5', + 'movie:20', + 'movie:3', + 'movie:4', + ]); + }); + + it('groups completed episodes only for a positive safe series id', () => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 70, + seasonNumber: 1, + episodeNumber: 1, + }), + download(2, { + contentType: 'episode', + seriesXtreamId: 70, + seasonNumber: 1, + episodeNumber: 2, + }), + ]); + + expect(result.library).toHaveLength(1); + expect(result.library[0]).toMatchObject({ + kind: 'series', + key: 'series:playlist-a:70', + seriesXtreamId: 70, + }); + }); + + it('keeps identical series ids from different playlists in separate groups', () => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 70, + }), + download(2, { + playlistId: 'playlist-b', + contentType: 'episode', + seriesXtreamId: 70, + }), + ]); + + expect(result.library.map(({ key }) => key).sort()).toEqual([ + 'series:playlist-a:70', + 'series:playlist-b:70', + ]); + }); + + it.each([ + ['invalid', Number.NaN], + ['zero', 0], + ['negative', -1], + ['fractional', 42.5], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ['missing', undefined], + ])( + 'keeps an episode with a %s series id as an individual card', + (_description, seriesXtreamId) => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId, + seasonNumber: 1, + episodeNumber: 4, + }), + ]); + + expect(result.library).toEqual([ + expect.objectContaining({ + kind: 'episode', + key: 'episode:1', + episodeLabel: 'S01E04', + }), + ]); + expect('seriesXtreamId' in result.library[0]).toBe(false); + } + ); + + it('prefers a standardized series prefix and otherwise keeps the first non-empty legacy title', () => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + title: 'Legacy chapter', + }), + download(2, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 2, + title: 'Canonical Show - S01E02 - Second', + }), + download(3, { + contentType: 'episode', + seriesXtreamId: 20, + seasonNumber: 1, + episodeNumber: 1, + title: ' ', + }), + download(4, { + contentType: 'episode', + seriesXtreamId: 20, + seasonNumber: 1, + episodeNumber: 2, + title: 'Original legacy title', + }), + ]); + + const series = result.library.filter( + (entity) => entity.kind === 'series' + ); + expect( + series.find(({ seriesXtreamId }) => seriesXtreamId === 10)?.title + ).toBe('Canonical Show'); + expect( + series.find(({ seriesXtreamId }) => seriesXtreamId === 20)?.title + ).toBe('Original legacy title'); + }); + + it('uses the newest member as representative', () => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-02-01T00:00:00Z', + }), + download(9, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-03-01T00:00:00Z', + }), + download(8, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-03-01T00:00:00Z', + }), + ]); + + const [series] = result.library; + expect(series.kind).toBe('series'); + if (series.kind === 'series') { + expect(series.representative.id).toBe(9); + expect(series.newestTimestamp).toBe( + Date.parse('2026-03-01T00:00:00Z') + ); + } + }); + + it('uses the newest member with a non-empty poster for series artwork', () => { + const result = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-01-01T00:00:00Z', + posterUrl: 'https://image.test/old.jpg', + }), + download(2, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-02-01T00:00:00Z', + posterUrl: 'https://image.test/new.jpg', + }), + download(3, { + contentType: 'episode', + seriesXtreamId: 10, + createdAt: '2026-03-01T00:00:00Z', + posterUrl: ' ', + }), + ]); + + expect(result.library[0]).toMatchObject({ + kind: 'series', + posterUrl: 'https://image.test/new.jpg', + }); + }); + + it('orders series members by season, episode, timestamp, then id with invalid numbers last', () => { + const episodes = [ + download(9, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: undefined, + episodeNumber: 1, + }), + download(8, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 2, + episodeNumber: 1, + }), + download(7, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: undefined, + }), + download(6, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + createdAt: '2026-01-01T00:00:00Z', + }), + download(5, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + createdAt: '2025-01-01T00:00:00Z', + }), + download(4, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 1, + createdAt: '2025-01-01T00:00:00Z', + }), + download(3, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + episodeNumber: 2, + }), + ]; + + const [series] = build(episodes).library; + expect(series.kind).toBe('series'); + if (series.kind === 'series') { + expect(series.members.map(({ id }) => id)).toEqual([ + 4, 5, 6, 3, 7, 8, 9, + ]); + } + }); + + it('derives the usable season range for a series', () => { + const [series] = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 4, + }), + download(2, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: Number.POSITIVE_INFINITY, + }), + download(3, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + }), + download(4, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: -1, + }), + ]).library; + + expect(series).toMatchObject({ + kind: 'series', + firstSeason: 1, + lastSeason: 4, + }); + }); + + it('aggregates tracked bytes across every member of a series', () => { + const [series] = build([ + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + bytesDownloaded: 30, + }), + download(2, { + contentType: 'episode', + seriesXtreamId: 10, + bytesDownloaded: undefined, + }), + download(3, { + contentType: 'episode', + seriesXtreamId: 10, + bytesDownloaded: 12, + }), + ]).library; + + expect(series).toMatchObject({ + kind: 'series', + trackedBytes: 42, + }); + }); + + it('does not mutate input arrays or objects while sorting and grouping', () => { + const first = Object.freeze( + download(2, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 2, + }) + ); + const second = Object.freeze( + download(1, { + contentType: 'episode', + seriesXtreamId: 10, + seasonNumber: 1, + }) + ); + const downloads = Object.freeze([first, second]); + const playlist = Object.freeze({ + _id: 'playlist-a', + title: 'Alpha Source', + }); + const playlists = Object.freeze([playlist]); + + const result = build(downloads, { playlists }); + + expect(downloads).toEqual([first, second]); + expect(playlists).toEqual([playlist]); + expect(result.scopedItems[0]).toBe(first); + const [series] = result.library; + expect(series.kind).toBe('series'); + if (series.kind === 'series') { + expect(series.members.map(({ id }) => id)).toEqual([1, 2]); + } + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts new file mode 100644 index 000000000..3c14397ec --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -0,0 +1,240 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { + buildDownloadLibrary, + compareDownloadIds, + deriveStandardizedSeriesTitle, + isUsableDownloadOrderNumber, + normalizedDownloadTimestamp, + type DownloadLibraryEntity, + type DownloadLibraryRow, +} from './download-library.viewmodel'; + +export type { + DownloadEpisodeCardViewModel, + DownloadLibraryEntity, + DownloadMovieCardViewModel, + DownloadSeriesCardViewModel, +} from './download-library.viewmodel'; + +export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress'; + +export type DownloadAttentionReason = 'file-missing' | 'transfer'; + +export interface DownloadListItemViewModel extends DownloadLibraryRow { + readonly attentionReason: DownloadAttentionReason; + readonly seriesTitle: string; +} + +export interface DownloadManagerCounts { + readonly all: number; + readonly movie: number; + readonly series: number; + readonly inProgress: number; +} + +export interface DownloadManagerViewModel { + readonly scopedItems: readonly DownloadItem[]; + readonly active: readonly DownloadListItemViewModel[]; + readonly attention: readonly DownloadListItemViewModel[]; + readonly library: readonly DownloadLibraryEntity[]; + readonly counts: DownloadManagerCounts; + readonly activeCount: number; + readonly trackedBytes: number; + readonly hasClearable: boolean; +} + +export interface BuildDownloadManagerViewModelInput { + readonly downloads: readonly DownloadItem[]; + readonly playlists: readonly Pick[]; + readonly scopePlaylistId?: string; + readonly filter: string | null | undefined; + readonly searchTerm?: string; +} + +export function normalizeDownloadFilter( + value: string | null | undefined +): DownloadFilterId { + switch (value) { + case 'movie': + case 'series': + case 'in-progress': + return value; + default: + return 'all'; + } +} + +function episodeLabel(item: DownloadItem): string { + if ( + item.contentType !== 'episode' || + !isUsableDownloadOrderNumber(item.seasonNumber) || + !isUsableDownloadOrderNumber(item.episodeNumber) + ) { + return ''; + } + + const season = String(item.seasonNumber).padStart(2, '0'); + const episode = String(item.episodeNumber).padStart(2, '0'); + return `S${season}E${episode}`; +} + +function seriesTitle(item: DownloadItem): string { + if (item.contentType !== 'episode') { + return ''; + } + return deriveStandardizedSeriesTitle(item) ?? item.title; +} + +function isActive(item: DownloadItem): boolean { + return ( + item.status === 'queued' || + item.status === 'downloading' || + item.status === 'paused' + ); +} + +function needsAttention(item: DownloadItem): boolean { + return item.status === 'failed' || item.status === 'canceled'; +} + +function isMissingCompletedFile(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability === 'missing'; +} + +function isReady(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability !== 'missing'; +} + +function compareQueued( + left: DownloadListItemViewModel, + right: DownloadListItemViewModel +): number { + return ( + normalizedDownloadTimestamp(left.item.createdAt) - + normalizedDownloadTimestamp(right.item.createdAt) || + compareDownloadIds(left.item.id, right.item.id) + ); +} + +function matchesSearch( + row: DownloadListItemViewModel, + searchTerm: string +): boolean { + const query = searchTerm.trim().toLowerCase(); + return ( + query.length === 0 || + [ + row.item.title, + row.seriesTitle, + row.sourceName, + row.episodeLabel, + row.item.errorMessage ?? '', + ].some((value) => value.toLowerCase().includes(query)) + ); +} + +function matchesFilter( + row: DownloadListItemViewModel, + filter: DownloadFilterId +): boolean { + if (filter === 'in-progress') { + return isActive(row.item); + } + if (filter === 'movie') { + return row.item.contentType === 'vod'; + } + if (filter === 'series') { + return row.item.contentType === 'episode'; + } + return true; +} + +function countContentType( + rows: readonly DownloadListItemViewModel[], + contentType: DownloadItem['contentType'] +): number { + return rows.filter((row) => row.item.contentType === contentType).length; +} + +export function buildDownloadManagerViewModel({ + downloads, + playlists, + scopePlaylistId, + filter, + searchTerm = '', +}: BuildDownloadManagerViewModelInput): DownloadManagerViewModel { + const sourceNames = new Map( + playlists.map(({ _id, title }) => [_id, title] as const) + ); + const scopedItems = downloads.filter( + (item) => + scopePlaylistId === undefined || item.playlistId === scopePlaylistId + ); + const scopedRows = scopedItems.map((item) => ({ + attentionReason: isMissingCompletedFile(item) + ? ('file-missing' as const) + : ('transfer' as const), + item, + episodeLabel: episodeLabel(item), + seriesTitle: seriesTitle(item), + sourceName: sourceNames.get(item.playlistId) ?? '', + })); + const scopedActive = scopedRows.filter(({ item }) => isActive(item)); + const scopedAttention = scopedRows.filter( + ({ item }) => needsAttention(item) || isMissingCompletedFile(item) + ); + const scopedLibrary = buildDownloadLibrary( + scopedRows.filter(({ item }) => isReady(item)) + ); + const activeFilter = normalizeDownloadFilter(filter); + const displayed = scopedRows + .filter((row) => matchesSearch(row, searchTerm)) + .filter((row) => matchesFilter(row, activeFilter)); + const active = displayed + .filter(({ item }) => isActive(item)) + .sort(compareQueued); + const attention = displayed + .filter( + ({ item }) => needsAttention(item) || isMissingCompletedFile(item) + ) + .sort(compareQueued); + const library = buildDownloadLibrary( + displayed.filter(({ item }) => isReady(item)) + ); + + return { + scopedItems, + active, + attention, + library, + counts: { + all: + scopedActive.length + + scopedAttention.length + + scopedLibrary.length, + movie: + countContentType(scopedActive, 'vod') + + countContentType(scopedAttention, 'vod') + + scopedLibrary.filter(({ kind }) => kind === 'movie').length, + series: + countContentType(scopedActive, 'episode') + + countContentType(scopedAttention, 'episode') + + scopedLibrary.filter(({ kind }) => kind !== 'movie').length, + inProgress: scopedActive.length, + }, + activeCount: scopedItems.filter( + (item) => item.status === 'queued' || item.status === 'downloading' + ).length, + trackedBytes: scopedItems.reduce( + (total, item) => total + (item.bytesDownloaded ?? 0), + 0 + ), + hasClearable: scopedItems.some( + (item) => + item.status === 'completed' || + item.status === 'failed' || + item.status === 'canceled' + ), + }; +} diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html new file mode 100644 index 000000000..b1d41b122 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -0,0 +1,388 @@ +@for (section of sections(); track section.testId) { +
+
+

+ {{ section.headingKey | translate }} +

+
+ +
+ @for (row of section.items; track row.item.id) { +
+ + +
+
+ + @if (row.episodeLabel.trim()) { + + {{ row.episodeLabel }} + + } +
+ + + + @if (progressMode(row.item); as mode) { + + } +
+ +
+ @if (isMissingFile(row)) { + + } @else { + @switch (row.item.status) { + @case ('queued') { + + + } + @case ('downloading') { + + + } + @case ('paused') { + + + + } + @case ('failed') { + + + } + @case ('canceled') { + + + } + } + } + + + + + + @if (isMissingFile(row)) { + + } + +
+
+ } +
+
+} + + + + + + + + + + + + + + + + diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.scss b/libs/portal/downloads/feature/src/lib/download-queue.component.scss new file mode 100644 index 000000000..4e46bc5b7 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.scss @@ -0,0 +1,342 @@ +:host { + display: block; + container-type: inline-size; +} + +.download-queue { + overflow: hidden; + border: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 78%, + transparent + ); + border-radius: 14px; + background: var(--mat-sys-surface-container-low); + + & + & { + margin-top: 16px; + } + + &__header { + padding: 11px 16px; + border-bottom: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 72%, + transparent + ); + background: var( + --app-widget-header-bg, + var(--mat-sys-surface-container) + ); + + h2 { + margin: 0; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-title-small); + letter-spacing: 0.01em; + } + } + + &__list { + display: grid; + } + + &__item { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + min-width: 0; + padding: 12px 14px; + + & + & { + border-top: 1px solid + color-mix( + in srgb, + var(--app-separator, var(--mat-sys-outline-variant)) 64%, + transparent + ); + } + + &--pending { + cursor: progress; + + .download-queue__artwork, + .download-queue__actions { + opacity: 0.68; + } + } + + &--missing { + background: color-mix( + in srgb, + var(--mat-sys-tertiary-container) 18%, + var(--mat-sys-surface-container-low) + ); + + .download-queue__artwork { + filter: saturate(0.42); + } + } + } + + &__artwork { + position: relative; + width: 58px; + height: 80px; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 9px; + background: var(--mat-sys-surface-container-highest); + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + transition: + transform 140ms ease, + box-shadow 140ms ease; + + img, + .download-queue__poster-placeholder { + width: 100%; + height: 100%; + } + + img { + display: block; + object-fit: cover; + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--mat-sys-level1); + } + } + + &__poster-placeholder { + display: grid; + place-items: center; + background: color-mix( + in srgb, + var(--mat-sys-primary-container) 44%, + var(--mat-sys-surface-container-highest) + ); + color: var(--mat-sys-on-primary-container); + + mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + } + } + + &__content { + display: grid; + gap: 8px; + min-width: 0; + } + + &__title-line, + &__metadata, + &__actions { + display: flex; + align-items: center; + } + + &__title-line { + gap: 8px; + min-width: 0; + } + + &__title { + min-width: 0; + overflow: hidden; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-title-medium); + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + &:hover:not(:disabled) { + color: var(--mat-sys-primary); + } + } + + &__episode { + flex: none; + padding: 2px 7px; + border-radius: 999px; + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + font: var(--mat-sys-label-small); + } + + &__metadata { + flex-wrap: wrap; + gap: 6px 12px; + min-width: 0; + color: var(--mat-sys-on-surface-variant); + font: var(--mat-sys-body-small); + } + + &__status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 999px; + font: var(--mat-sys-label-medium); + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + + &--queued { + background: var(--mat-sys-surface-container-highest); + color: var(--mat-sys-on-surface-variant); + } + + &--downloading { + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + } + + &--paused { + background: var(--mat-sys-tertiary-container); + color: var(--mat-sys-on-tertiary-container); + } + + &--failed, + &--canceled { + background: var(--mat-sys-error-container); + color: var(--mat-sys-on-error-container); + } + + &--missing { + background: var(--mat-sys-tertiary-container); + color: var(--mat-sys-on-tertiary-container); + } + } + + &__bytes { + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-variant-numeric: tabular-nums; + } + + &__error { + min-width: 0; + max-width: 100%; + color: var(--mat-sys-error); + font-weight: 500; + overflow-wrap: anywhere; + } + + &__progress { + width: min(420px, 100%); + border-radius: 999px; + } + + &__actions { + justify-content: flex-end; + gap: 2px; + + button { + flex: none; + transition: + background-color 140ms ease, + transform 140ms ease; + + &:hover:not(:disabled) { + background: var( + --app-selection-surface, + var(--mat-sys-secondary-container) + ); + transform: translateY(-1px); + } + } + } + + &__recover { + white-space: nowrap; + + mat-icon { + margin-inline-end: 6px; + } + } + + button:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; + } + + button:disabled { + cursor: default; + } +} + +@container (max-width: 680px) { + .download-queue { + &__item { + grid-template-columns: 52px minmax(0, 1fr); + align-items: start; + gap: 10px 12px; + } + + &__artwork { + width: 52px; + height: 74px; + } + + &__actions { + grid-column: 2; + justify-content: flex-end; + margin-top: -4px; + } + + &__progress { + width: 100%; + } + } +} + +@container (max-width: 430px) { + .download-queue { + &__item { + padding-inline: 10px; + } + + &__title-line { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + &__metadata { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .download-queue { + &__artwork, + &__actions button { + transition: none; + + &:hover:not(:disabled) { + transform: none; + } + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts new file mode 100644 index 000000000..e2aa41708 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -0,0 +1,655 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatProgressBarHarness } from '@angular/material/progress-bar/testing'; +import { MatTooltipHarness } from '@angular/material/tooltip/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { DownloadItemAction } from './download-actions'; +import type { DownloadListItemViewModel } from './download-manager.viewmodel'; +import { + DownloadQueueComponent, + formatDownloadBytes, +} from './download-queue.component'; + +const TEST_TRANSLATIONS = { + DOWNLOADS: { + DOWNLOADING_NOW: 'Downloading now', + NEEDS_ATTENTION: 'Needs attention', + STATUS: { + QUEUED: 'Queued', + DOWNLOADING: 'Downloading', + PAUSED: 'Paused', + FAILED: 'Failed', + CANCELED: 'Canceled', + FILE_MISSING: 'File missing', + }, + DOWNLOAD_AGAIN: 'Download again', + ARIA: { + OPEN: 'Open {{title}}', + POSTER_UNAVAILABLE: 'Artwork unavailable for {{title}}', + PROGRESS: 'Download progress for {{title}}', + PAUSE: 'Pause {{title}}', + CANCEL: 'Cancel {{title}}', + RESUME: 'Resume {{title}}', + REMOVE: 'Remove {{title}} from manager', + RETRY: 'Retry {{title}}', + MORE_ACTIONS: 'More actions for {{title}}', + COPY_URL: 'Copy URL for {{title}}', + DOWNLOAD_AGAIN: 'Download {{title}} again', + }, + }, + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', + }, + }, +}; + +function createItem( + id: number, + status: DownloadItem['status'], + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: id * 10, + contentType: 'vod', + title: `Download ${id}`, + url: `https://example.test/${id}`, + status, + createdAt: '2026-07-30T10:00:00Z', + ...overrides, + }; +} + +function createRow( + id: number, + status: DownloadItem['status'], + overrides: Partial = {} +): DownloadListItemViewModel { + return { + attentionReason: 'transfer', + item: createItem(id, status), + episodeLabel: '', + seriesTitle: '', + sourceName: '', + ...overrides, + }; +} + +describe('DownloadQueueComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + activeItems: readonly DownloadListItemViewModel[] = [], + attentionItems: readonly DownloadListItemViewModel[] = [], + pendingIds: ReadonlySet = new Set() + ): void { + fixture.componentRef.setInput('activeItems', activeItems); + fixture.componentRef.setInput('attentionItems', attentionItems); + fixture.componentRef.setInput('pendingIds', pendingIds); + fixture.detectChanges(); + } + + function row(id: number): HTMLElement { + const element = fixture.nativeElement.querySelector( + `[data-test-id="download-queue-item-${id}"]` + ); + expect(element).not.toBeNull(); + return element; + } + + function actionButton(host: ParentNode, action: string): HTMLButtonElement { + const button = host.querySelector( + `[data-test-action="${action}"]` + ); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + DownloadQueueComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', TEST_TRANSLATIONS); + translate.use('en'); + overlayContainer = TestBed.inject(OverlayContainer); + fixture = TestBed.createComponent(DownloadQueueComponent); + }); + + afterEach(() => { + overlayContainer.getContainerElement().replaceChildren(); + }); + + it('omits empty sections and renders named sections with stable row IDs', () => { + const queued = createRow(1, 'queued'); + const failed = createRow(2, 'failed'); + + render([queued], []); + + const activeSection: HTMLElement = fixture.nativeElement.querySelector( + '[data-test-id="downloads-active-section"]' + ); + expect(activeSection).not.toBeNull(); + expect(activeSection.querySelector('h2')?.textContent?.trim()).toBe( + 'Downloading now' + ); + expect(activeSection.getAttribute('aria-labelledby')).toBeTruthy(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-attention-section"]' + ) + ).toBeNull(); + expect(row(1)).not.toBeNull(); + + render([], [failed]); + + const attentionSection: HTMLElement = + fixture.nativeElement.querySelector( + '[data-test-id="downloads-attention-section"]' + ); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-active-section"]' + ) + ).toBeNull(); + expect(attentionSection.querySelector('h2')?.textContent?.trim()).toBe( + 'Needs attention' + ); + expect(row(2)).not.toBeNull(); + + render(); + + expect(fixture.nativeElement.querySelector('section')).toBeNull(); + }); + + it.each([ + ['queued', 'Queued', 'schedule'], + ['downloading', 'Downloading', 'downloading'], + ['paused', 'Paused', 'pause_circle'], + ['failed', 'Failed', 'error'], + ['canceled', 'Canceled', 'cancel'], + ] as const)( + 'renders translated %s status text with its semantic icon', + (status, text, icon) => { + const item = createRow(1, status); + render( + status === 'failed' || status === 'canceled' ? [] : [item], + status === 'failed' || status === 'canceled' ? [item] : [] + ); + + const statusPill = row(1).querySelector( + '.download-queue__status' + ); + expect(statusPill?.textContent).toContain(text); + expect(statusPill?.getAttribute('role')).toBe('status'); + expect(statusPill?.getAttribute('aria-live')).toBe('polite'); + expect(statusPill?.getAttribute('aria-atomic')).toBe('true'); + expect( + statusPill?.querySelector('mat-icon')?.textContent?.trim() + ).toBe(icon); + } + ); + + it('renders a missing completed file as recoverable attention', async () => { + const viewModel = createRow(8, 'completed', { + attentionReason: 'file-missing', + item: createItem(8, 'completed', { + bytesDownloaded: 2 * 1024 ** 2, + fileAvailability: 'missing', + filePath: '/downloads/missing.mp4', + }), + sourceName: 'Alpha Source', + }); + const emitted: DownloadItemAction[] = []; + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + render([], [viewModel]); + + const host = row(8); + const status = host.querySelector( + '.download-queue__status' + ); + const renderedActions = Array.from( + host.querySelectorAll( + '[data-test-action]:not([data-test-action="more"])' + ) + ).map((button) => button.dataset['testAction']); + expect(status?.textContent).toContain('File missing'); + expect(status?.querySelector('mat-icon')?.textContent?.trim()).toBe( + 'file_off' + ); + expect(renderedActions).toEqual(['redownload']); + expect(host.querySelector('[data-test-action="play"]')).toBeNull(); + expect(host.querySelector('[data-test-action="reveal"]')).toBeNull(); + expect(host.querySelector('.download-queue__source')).toBeNull(); + + actionButton(host, 'redownload').click(); + expect(emitted).toEqual([{ type: 'redownload', item: viewModel.item }]); + + actionButton(host, 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const menu = overlayContainer.getContainerElement(); + const header = menu.querySelector( + '.download-source-menu-header' + ); + const copy = actionButton(menu, 'copy-url'); + const remove = actionButton(menu, 'remove'); + expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'Source Alpha Source' + ); + expect(header?.querySelector('strong')?.textContent).toContain( + 'Alpha Source' + ); + expect( + header && + header.compareDocumentPosition(copy) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + copy.compareDocumentPosition(remove) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('disables Download again while pending and omits an empty source header', async () => { + const viewModel = createRow(9, 'completed', { + attentionReason: 'file-missing', + item: createItem(9, 'completed', { + fileAvailability: 'missing', + filePath: '/downloads/missing.mp4', + }), + sourceName: ' ', + }); + render([], [viewModel], new Set([9])); + + expect(actionButton(row(9), 'redownload').disabled).toBe(true); + fixture.componentRef.setInput('pendingIds', new Set()); + fixture.detectChanges(); + actionButton(row(9), 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect( + overlayContainer + .getContainerElement() + .querySelector('.download-source-menu-header') + ).toBeNull(); + }); + + it.each([ + ['queued', ['pause', 'cancel']], + ['downloading', ['pause', 'cancel']], + ['paused', ['resume', 'cancel', 'remove']], + ['failed', ['retry', 'remove']], + ['canceled', ['retry', 'remove']], + ] as const)( + 'exposes the exact primary action contract for %s', + (status, expectedActions) => { + const viewModel = createRow(7, status); + const emitted: DownloadItemAction[] = []; + const opened = jest.fn(); + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + fixture.componentInstance.openRequested.subscribe(opened); + render( + status === 'failed' || status === 'canceled' ? [] : [viewModel], + status === 'failed' || status === 'canceled' ? [viewModel] : [] + ); + + const host = row(7); + const renderedActions = Array.from( + host.querySelectorAll( + '[data-test-action]:not([data-test-action="more"])' + ) + ).map((button) => button.dataset['testAction']); + expect(renderedActions).toEqual(expectedActions); + + for (const action of expectedActions) { + actionButton(host, action).click(); + } + + expect(emitted.map(({ type }) => type)).toEqual(expectedActions); + for (const event of emitted) { + expect(event.item).toBe(viewModel.item); + } + expect(opened).not.toHaveBeenCalled(); + } + ); + + it('emits copy-url with the original item only through the Material overflow menu', async () => { + const viewModel = createRow(11, 'queued'); + const emitted = jest.fn(); + const opened = jest.fn(); + fixture.componentInstance.itemAction.subscribe(emitted); + fixture.componentInstance.openRequested.subscribe(opened); + render([viewModel]); + + expect( + row(11).querySelector('[data-test-action="copy-url"]') + ).toBeNull(); + actionButton(row(11), 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const copyButton = actionButton( + overlayContainer.getContainerElement(), + 'copy-url' + ); + expect(copyButton.closest('.mat-mdc-menu-panel')).not.toBeNull(); + expect(copyButton.textContent).toContain('Copy URL for Download 11'); + copyButton.click(); + + expect(emitted).toHaveBeenCalledTimes(1); + expect(emitted.mock.calls[0][0]).toEqual({ + type: 'copy-url', + item: viewModel.item, + }); + expect(emitted.mock.calls[0][0].item).toBe(viewModel.item); + expect(opened).not.toHaveBeenCalled(); + }); + + it('opens the original item from real artwork and title buttons only', () => { + const viewModel = createRow(5, 'downloading'); + const opened: DownloadItem[] = []; + fixture.componentInstance.openRequested.subscribe((item) => + opened.push(item) + ); + render([viewModel]); + + const artwork = row(5).querySelector( + '[data-test-control="artwork"]' + ); + const title = row(5).querySelector( + '[data-test-control="title"]' + ); + expect(artwork?.tagName).toBe('BUTTON'); + expect(artwork?.type).toBe('button'); + expect(title?.tagName).toBe('BUTTON'); + expect(title?.type).toBe('button'); + + artwork?.click(); + title?.click(); + + expect(opened).toEqual([viewModel.item, viewModel.item]); + expect(opened[0]).toBe(viewModel.item); + expect(opened[1]).toBe(viewModel.item); + }); + + it('disables navigation, primary actions, overflow, and an open menu command while pending', async () => { + const viewModel = createRow(17, 'paused'); + const emitted = jest.fn(); + const opened = jest.fn(); + fixture.componentInstance.itemAction.subscribe(emitted); + fixture.componentInstance.openRequested.subscribe(opened); + render([viewModel]); + expect(row(17).getAttribute('aria-busy')).toBeNull(); + + actionButton(row(17), 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + actionButton(overlayContainer.getContainerElement(), 'copy-url') + .disabled + ).toBe(false); + + fixture.componentRef.setInput('pendingIds', new Set([17])); + fixture.detectChanges(); + + const itemButtons = Array.from( + row(17).querySelectorAll('button') + ); + expect(row(17).getAttribute('aria-busy')).toBe('true'); + expect( + row(17).querySelector('.download-queue__metadata') + ).not.toBeNull(); + expect(itemButtons).toHaveLength(6); + expect(itemButtons.every(({ disabled }) => disabled)).toBe(true); + expect( + actionButton(overlayContainer.getContainerElement(), 'copy-url') + .disabled + ).toBe(true); + + fixture.componentInstance.emitAction('remove', viewModel.item); + fixture.componentInstance.requestOpen(viewModel.item); + expect(emitted).not.toHaveBeenCalled(); + expect(opened).not.toHaveBeenCalled(); + }); + + it('renders determinate and indeterminate progress, omits meaningless paused progress, and clamps values', async () => { + const known = createRow(1, 'downloading', { + item: createItem(1, 'downloading', { + bytesDownloaded: 250, + totalBytes: 200, + }), + }); + const unknown = createRow(2, 'queued', { + item: createItem(2, 'queued', { + bytesDownloaded: 10, + totalBytes: undefined, + }), + }); + const pausedUnknown = createRow(3, 'paused', { + item: createItem(3, 'paused', { + bytesDownloaded: 20, + totalBytes: 0, + }), + }); + const pausedKnown = createRow(4, 'paused', { + item: createItem(4, 'paused', { + bytesDownloaded: -50, + totalBytes: 100, + }), + }); + render([known, unknown, pausedUnknown, pausedKnown]); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const bars = await loader.getAllHarnesses(MatProgressBarHarness); + expect(bars).toHaveLength(3); + expect(await Promise.all(bars.map((bar) => bar.getMode()))).toEqual([ + 'determinate', + 'indeterminate', + 'determinate', + ]); + expect(await bars[0].getValue()).toBe(100); + expect(await bars[2].getValue()).toBe(0); + expect( + fixture.componentInstance.progressValue( + createItem(5, 'queued', { + bytesDownloaded: Number.POSITIVE_INFINITY, + totalBytes: 100, + }) + ) + ).toBe(0); + expect(row(3).querySelector('.download-queue__progress')).toBeNull(); + expect( + Array.from( + fixture.nativeElement.querySelectorAll( + '.download-queue__progress' + ) + ).map((bar) => bar.getAttribute('aria-label')) + ).toEqual([ + 'Download progress for Download 1', + 'Download progress for Download 2', + 'Download progress for Download 4', + ]); + expect( + fixture.nativeElement.querySelector( + '.download-queue__progress[aria-live]' + ) + ).toBeNull(); + }); + + it.each([ + [undefined, '0 B'], + [0, '0 B'], + [-1, '0 B'], + [Number.NaN, '0 B'], + [Number.POSITIVE_INFINITY, '0 B'], + [0.5, '0.5 B'], + [1023, '1023 B'], + [1024, '1 KB'], + [1.5 * 1024 ** 2, '1.5 MB'], + [2 * 1024 ** 3, '2 GB'], + [3.25 * 1024 ** 4, '3.3 TB'], + [1024 ** 5, '1024 TB'], + ])('formats byte metadata %p as %s', (bytes, expected) => { + expect(formatDownloadBytes(bytes)).toBe(expected); + }); + + it('renders downloaded/total bytes and episode/error metadata without a permanent source', () => { + const longError = + 'https://downloads.example.test/a/very/long/unbroken/path/that/must/remain/readable/error-code-connection-reset'; + const detailed = createRow(21, 'failed', { + item: createItem(21, 'failed', { + contentType: 'episode', + bytesDownloaded: 1.5 * 1024 ** 2, + totalBytes: 2 * 1024 ** 2, + errorMessage: longError, + }), + episodeLabel: 'S02E04', + seriesTitle: 'Example series', + sourceName: 'Alpha Source', + }); + const noSource = createRow(22, 'canceled', { + item: createItem(22, 'canceled', { + bytesDownloaded: undefined, + totalBytes: undefined, + }), + sourceName: ' ', + }); + render([], [detailed, noSource]); + + expect( + row(21) + .querySelector('.download-queue__bytes') + ?.textContent?.replace(/\s+/g, ' ') + .trim() + ).toBe('1.5 MB / 2 MB'); + expect( + row(21) + .querySelector('.download-queue__episode') + ?.textContent?.trim() + ).toBe('S02E04'); + expect(row(21).querySelector('.download-queue__source')).toBeNull(); + const error = row(21).querySelector( + '.download-queue__error' + ); + expect(error?.textContent?.trim()).toBe(longError); + expect(row(22).querySelector('.download-queue__source')).toBeNull(); + expect( + row(22).querySelector('.download-queue__bytes')?.textContent + ).toContain('0 B'); + }); + + it('uses a lazy decorative image and swaps missing or broken posters for a semantic placeholder', () => { + const withPoster = createRow(31, 'queued', { + item: createItem(31, 'queued', { + title: 'Poster title', + posterUrl: 'https://example.test/poster.jpg', + }), + }); + const missingPoster = createRow(32, 'queued', { + item: createItem(32, 'queued', { + title: 'Missing title', + posterUrl: ' ', + }), + }); + render([withPoster, missingPoster]); + + const image = row(31).querySelector('img'); + expect(image?.getAttribute('loading')).toBe('lazy'); + expect(image?.alt).toBe(''); + expect( + row(32).querySelector('[role="img"]')?.getAttribute('aria-label') + ).toBe('Artwork unavailable for Missing title'); + + image?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect(row(31).querySelector('img')).toBeNull(); + expect( + row(31).querySelector('[role="img"]')?.getAttribute('aria-label') + ).toBe('Artwork unavailable for Poster title'); + fixture.componentInstance.markPosterFailed(withPoster.item); + expect(fixture.componentInstance.hasPoster(withPoster.item)).toBe( + false + ); + }); + + it('gives every command an item-specific translated accessible name and tooltip', async () => { + const queued = createRow(41, 'queued', { + item: createItem(41, 'queued', { title: 'Named movie' }), + }); + const paused = createRow(42, 'paused', { + item: createItem(42, 'paused', { title: 'Paused episode' }), + }); + const failed = createRow(43, 'failed', { + item: createItem(43, 'failed', { title: 'Failed movie' }), + }); + render([queued, paused], [failed]); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const commands = [ + [41, 'pause', 'Pause Named movie'], + [41, 'cancel', 'Cancel Named movie'], + [41, 'more', 'More actions for Named movie'], + [42, 'resume', 'Resume Paused episode'], + [42, 'remove', 'Remove Paused episode from manager'], + [43, 'retry', 'Retry Failed movie'], + ] as const; + + for (const [id, action, accessibleName] of commands) { + expect( + actionButton(row(id), action).getAttribute('aria-label') + ).toBe(accessibleName); + + const tooltip = await loader.getHarness( + MatTooltipHarness.with({ + selector: `[data-test-id="download-queue-item-${id}"] [data-test-action="${action}"]`, + }) + ); + await tooltip.show(); + expect(await tooltip.getTooltipText()).toBe(accessibleName); + await tooltip.hide(); + } + + actionButton(row(41), 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + const copyButton = actionButton( + overlayContainer.getContainerElement(), + 'copy-url' + ); + expect(copyButton.type).toBe('button'); + expect(copyButton.getAttribute('aria-label')).toBe( + 'Copy URL for Named movie' + ); + const documentLoader = + TestbedHarnessEnvironment.documentRootLoader(fixture); + const copyTooltip = await documentLoader.getHarness( + MatTooltipHarness.with({ + selector: '[data-test-action="copy-url"]', + }) + ); + await copyTooltip.show(); + expect(await copyTooltip.getTooltipText()).toBe( + 'Copy URL for Named movie' + ); + await copyTooltip.hide(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.ts new file mode 100644 index 000000000..a029d7f9d --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.ts @@ -0,0 +1,197 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { + DownloadItemAction, + DownloadItemActionType, +} from './download-actions'; +import type { DownloadListItemViewModel } from './download-manager.viewmodel'; +import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component'; + +type DownloadProgressMode = 'determinate' | 'indeterminate'; + +interface DownloadQueueSection { + readonly headingId: string; + readonly headingKey: string; + readonly items: readonly DownloadListItemViewModel[]; + readonly testId: string; +} + +interface DownloadItemWithKnownTotal extends DownloadItem { + readonly totalBytes: number; +} + +const STATUS_ICONS: Readonly> = { + queued: 'schedule', + downloading: 'downloading', + paused: 'pause_circle', + completed: 'check_circle', + failed: 'error', + canceled: 'cancel', +}; + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +export function formatDownloadBytes(bytes: number | undefined): string { + if (!Number.isFinite(bytes) || bytes === undefined || bytes <= 0) { + return '0 B'; + } + + const unitIndex = Math.max( + 0, + Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + BYTE_UNITS.length - 1 + ) + ); + const value = bytes / 1024 ** unitIndex; + const rounded = Math.round(value * 10) / 10; + return `${rounded} ${BYTE_UNITS[unitIndex]}`; +} + +@Component({ + selector: 'app-download-queue', + imports: [ + MatButtonModule, + MatIcon, + MatMenuModule, + MatProgressBarModule, + MatTooltip, + NgTemplateOutlet, + TranslatePipe, + DownloadSourceMenuHeaderComponent, + ], + templateUrl: './download-queue.component.html', + styleUrl: './download-queue.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadQueueComponent { + readonly activeItems = + input.required(); + readonly attentionItems = + input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + private readonly failedPosterIds = signal>(new Set()); + + readonly sections = computed(() => { + const sections: DownloadQueueSection[] = [ + { + headingId: 'downloads-active-heading', + headingKey: 'DOWNLOADS.DOWNLOADING_NOW', + items: this.activeItems(), + testId: 'downloads-active-section', + }, + { + headingId: 'downloads-attention-heading', + headingKey: 'DOWNLOADS.NEEDS_ATTENTION', + items: this.attentionItems(), + testId: 'downloads-attention-section', + }, + ]; + return sections.filter(({ items }) => items.length > 0); + }); + + emitAction(type: DownloadItemActionType, item: DownloadItem): void { + if (!this.isPending(item.id)) { + this.itemAction.emit({ type, item }); + } + } + + requestOpen(item: DownloadItem): void { + if (!this.isPending(item.id)) { + this.openRequested.emit(item); + } + } + + isPending(id: number): boolean { + return this.pendingIds().has(id); + } + + isMissingFile(row: DownloadListItemViewModel): boolean { + return row.attentionReason === 'file-missing'; + } + + statusIcon(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) + ? 'file_off' + : STATUS_ICONS[row.item.status]; + } + + statusKey(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) + ? 'DOWNLOADS.STATUS.FILE_MISSING' + : `DOWNLOADS.STATUS.${row.item.status.toUpperCase()}`; + } + + statusClass(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) ? 'missing' : row.item.status; + } + + hasPoster(item: DownloadItem): boolean { + return ( + (item.posterUrl?.trim().length ?? 0) > 0 && + !this.failedPosterIds().has(item.id) + ); + } + + markPosterFailed(item: DownloadItem): void { + this.failedPosterIds.update((ids) => { + if (ids.has(item.id)) { + return ids; + } + const next = new Set(ids); + next.add(item.id); + return next; + }); + } + + hasKnownTotal(item: DownloadItem): item is DownloadItemWithKnownTotal { + return ( + item.totalBytes !== undefined && + Number.isFinite(item.totalBytes) && + item.totalBytes > 0 + ); + } + + progressMode(item: DownloadItem): DownloadProgressMode | null { + if (item.status === 'paused') { + return this.hasKnownTotal(item) ? 'determinate' : null; + } + if (item.status === 'queued' || item.status === 'downloading') { + return this.hasKnownTotal(item) ? 'determinate' : 'indeterminate'; + } + return null; + } + + progressValue(item: DownloadItem): number { + if (!this.hasKnownTotal(item)) { + return 0; + } + const downloaded = + item.bytesDownloaded !== undefined && + Number.isFinite(item.bytesDownloaded) + ? Math.max(0, item.bytesDownloaded) + : 0; + return Math.min(100, (downloaded / item.totalBytes) * 100); + } + + formatBytes(bytes: number | undefined): string { + return formatDownloadBytes(bytes); + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts new file mode 100644 index 000000000..aba611dae --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + selector: 'app-download-source-menu-header', + imports: [MatTooltip, TranslatePipe], + template: ` + @if (sourceName().trim(); as source) { +
+ + {{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }} + + {{ source }} +
+ } + `, + styles: ` + :host { + display: block; + } + + .download-source-menu-header { + display: grid; + gap: 2px; + max-width: 280px; + padding: 10px 16px 8px; + color: var(--mat-sys-on-surface-variant); + + span { + font: var(--mat-sys-label-small); + letter-spacing: 0.03em; + } + + strong { + overflow: hidden; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-label-large); + text-overflow: ellipsis; + white-space: nowrap; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadSourceMenuHeaderComponent { + readonly sourceName = input(''); +} diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html new file mode 100644 index 000000000..ee278a52e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.html @@ -0,0 +1,117 @@ +
+
+ + {{ 'DOWNLOADS.DOWNLOADED_EPISODES' | translate }} + +

{{ group.title }}

+

+ {{ + episodeCountKey(currentMembers().length) + | translate: { count: currentMembers().length } + }} + @if (group.sourceName.trim()) { + + {{ group.sourceName }} + } +

+
+ +
+ + +
    + @for (member of currentMembers(); track member.id) { +
  1. +
    + @if (episodeLabel(member); as label) { + + {{ label }} + + } + + {{ member.title }} + + + {{ formatBytes(member.bytesDownloaded) }} + +
    + +
    + + + + +
    +
  2. + } +
+
diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss new file mode 100644 index 000000000..ffb1d147b --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss @@ -0,0 +1,158 @@ +:host { + display: block; + width: 100%; + min-width: 0; + color: var(--app-body-color); +} + +.downloaded-series-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 22px 24px 14px; + border-bottom: 1px solid var(--app-separator); + background: var(--app-widget-header-bg); + + h2[mat-dialog-title] { + margin: 3px 0 0; + padding: 0; + color: var(--app-heading-color); + font-size: 1.35rem; + font-weight: 700; + line-height: 1.25; + } + + p { + margin: 6px 0 0; + color: var(--app-muted-color); + font-size: 0.78rem; + } +} + +.downloaded-series-dialog__eyebrow { + color: var(--app-eyebrow-color); + font-size: 0.68rem; + font-weight: 750; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +mat-dialog-content { + max-height: min(66vh, 620px); + padding: 10px 12px 18px; + background: var(--app-widget-bg); +} + +.downloaded-series-dialog__list { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.downloaded-series-dialog__episode { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 64px; + padding: 8px 10px 8px 12px; + border: 1px solid transparent; + border-radius: 10px; + transition: + border-color 140ms ease, + background-color 140ms ease; + + &:hover, + &:focus-within { + border-color: var(--app-separator); + background: var(--app-card-hover-bg); + } +} + +.downloaded-series-dialog__identity { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 4px 10px; + min-width: 0; +} + +.downloaded-series-dialog__number { + grid-row: 1 / span 2; + min-width: 54px; + padding: 5px 7px; + border-radius: 6px; + background: color-mix( + in srgb, + var(--mat-sys-primary) 12%, + var(--app-widget-header-bg) + ); + color: var(--mat-sys-primary); + font-family: var(--mat-sys-label-small-font); + font-size: 0.73rem; + font-weight: 750; + text-align: center; +} + +.downloaded-series-dialog__title { + overflow: hidden; + color: var(--app-heading-color); + font-size: 0.84rem; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloaded-series-dialog__size { + color: var(--app-muted-color); + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.downloaded-series-dialog__actions { + display: flex; + align-items: center; + + button { + color: var(--app-body-color); + } +} + +button:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; +} + +button:disabled { + cursor: progress; +} + +@media (max-width: 620px) { + .downloaded-series-dialog__header { + padding: 18px 16px 12px; + } + + .downloaded-series-dialog__episode { + grid-template-columns: minmax(0, 1fr); + gap: 6px; + } + + .downloaded-series-dialog__actions { + justify-content: flex-end; + border-top: 1px solid var(--app-separator); + padding-top: 5px; + } +} + +@media (prefers-reduced-motion: reduce) { + .downloaded-series-dialog__episode { + transition: none; + } +} diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts new file mode 100644 index 000000000..dab0bf0ff --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.spec.ts @@ -0,0 +1,245 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { DownloadSeriesCardViewModel } from './download-library.viewmodel'; +import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component'; + +const TRANSLATIONS = { + CLOSE: 'Close', + DOWNLOADS: { + COPY_URL: 'Copy download URL', + DOWNLOADED_EPISODES: 'Downloaded episodes', + EPISODE_COUNT_ONE: '1 episode', + EPISODE_COUNT_OTHER: '{{count}} episodes', + PLAY: 'Play', + REMOVE_FROM_MANAGER: 'Remove from manager', + REVEAL: 'Show in folder', + }, +}; + +function episode(overrides: Partial): DownloadItem { + return { + id: 31, + playlistId: 'playlist-series', + xtreamId: 501, + contentType: 'episode', + seriesXtreamId: 88, + seasonNumber: 1, + episodeNumber: 1, + title: 'Signal House - S01E01 - Arrival', + url: 'https://media.example.test/episode', + status: 'completed', + bytesDownloaded: 1_500_000, + ...overrides, + }; +} + +const MEMBERS = [ + episode({ id: 31 }), + episode({ + id: 32, + xtreamId: 502, + episodeNumber: 3, + title: 'Signal House - S01E03 - Relay', + }), + episode({ + id: 33, + xtreamId: 503, + seasonNumber: 2, + episodeNumber: 1, + title: 'Signal House - S02E01 - Return', + }), +] as const; + +const GROUP: DownloadSeriesCardViewModel = { + kind: 'series', + key: 'series:playlist-series:88', + representative: MEMBERS[2], + members: MEMBERS, + seriesXtreamId: 88, + title: 'Signal House', + posterUrl: 'https://media.example.test/signal-house.jpg', + newestTimestamp: 3, + sourceName: 'Series source', + trackedBytes: 4_500_000, + firstSeason: 1, + lastSeason: 2, +}; + +describe('DownloadedSeriesDialogComponent', () => { + let fixture: ComponentFixture; + let component: DownloadedSeriesDialogComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + DownloadedSeriesDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [{ provide: MAT_DIALOG_DATA, useValue: GROUP }], + }).compileComponents(); + + TestBed.inject(TranslateService).setTranslation('en', TRANSLATIONS); + TestBed.inject(TranslateService).use('en'); + fixture = TestBed.createComponent(DownloadedSeriesDialogComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + function rows(): HTMLElement[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '[data-test-id^="downloaded-series-episode-"]' + ) + ); + } + + function button(row: ParentNode, label: string): HTMLButtonElement { + const result = Array.from(row.querySelectorAll('button')).find( + (candidate) => candidate.getAttribute('aria-label') === label + ); + if (!result) { + throw new Error(`Missing button: ${label}`); + } + return result as HTMLButtonElement; + } + + async function click(element: HTMLElement): Promise { + element.click(); + await fixture.whenStable(); + } + + it('renders members in view-model order with their episode identities', () => { + expect(rows().map((row) => row.getAttribute('data-test-id'))).toEqual([ + 'downloaded-series-episode-31', + 'downloaded-series-episode-32', + 'downloaded-series-episode-33', + ]); + expect(rows()[0].textContent).toContain('S01E01'); + expect(rows()[1].textContent).toContain('S01E03'); + expect(rows()[2].textContent).toContain('S02E01'); + }); + + it('gives the dialog close button an accessible name', () => { + const closeButton = fixture.nativeElement.querySelector( + '[data-test-id="downloaded-series-close"]' + ) as HTMLButtonElement | null; + + expect(closeButton?.getAttribute('aria-label')).toBe('Close'); + }); + + it('includes the concrete episode identity in every action name', () => { + rows().forEach((row, index) => { + const identity = ['S01E01', 'S01E03', 'S02E01'][index]; + const labels = Array.from( + row.querySelectorAll( + '.downloaded-series-dialog__actions button' + ) + ).map((candidate) => candidate.getAttribute('aria-label')); + + expect(labels).toHaveLength(4); + expect(labels.every((label) => label?.includes(identity))).toBe( + true + ); + }); + }); + + it('emits play, reveal, copy, and remove with the selected member', async () => { + const selected = MEMBERS[1]; + const row = rows()[1]; + const actions: unknown[] = []; + component.itemAction.subscribe((action) => actions.push(action)); + + await click( + button(row, 'Play: S01E03 — Signal House - S01E03 - Relay') + ); + await click( + button( + row, + 'Show in folder: S01E03 — Signal House - S01E03 - Relay' + ) + ); + await click( + button( + row, + 'Copy download URL: S01E03 — Signal House - S01E03 - Relay' + ) + ); + await click( + button( + row, + 'Remove from manager: S01E03 — Signal House - S01E03 - Relay' + ) + ); + + expect(actions).toEqual([ + { type: 'play', item: selected }, + { type: 'reveal', item: selected }, + { type: 'copy-url', item: selected }, + { type: 'remove', item: selected }, + ]); + }); + + it('disables and guards every command for a pending episode until it settles', async () => { + const pendingIds = signal>(new Set([32])); + fixture.componentRef.setInput('pendingIds', pendingIds); + await fixture.whenStable(); + const actions: unknown[] = []; + component.itemAction.subscribe((action) => actions.push(action)); + + expect( + Array.from(rows()[1].querySelectorAll('button')).every( + ({ disabled }) => disabled + ) + ).toBe(true); + expect(rows()[1].getAttribute('aria-busy')).toBe('true'); + expect( + Array.from(rows()[0].querySelectorAll('button')).every( + ({ disabled }) => !disabled + ) + ).toBe(true); + await click( + button(rows()[1], 'Play: S01E03 — Signal House - S01E03 - Relay') + ); + expect(actions).toEqual([]); + ( + component as unknown as { + emitAction(type: 'play', item: DownloadItem): void; + } + ).emitAction('play', MEMBERS[1]); + expect(actions).toEqual([]); + + pendingIds.set(new Set()); + await fixture.whenStable(); + await click( + button(rows()[1], 'Play: S01E03 — Signal House - S01E03 - Relay') + ); + expect(actions).toEqual([{ type: 'play', item: MEMBERS[1] }]); + }); + + it('removes episodes when the authoritative group membership changes', async () => { + const members = signal(MEMBERS); + const pendingIds = signal>(new Set([32])); + fixture.componentRef.setInput('members', members); + fixture.componentRef.setInput('pendingIds', pendingIds); + await fixture.whenStable(); + + expect(rows()).toHaveLength(3); + expect(rows()[1].getAttribute('aria-busy')).toBe('true'); + + members.set([MEMBERS[0], MEMBERS[2]]); + pendingIds.set(new Set()); + await fixture.whenStable(); + + expect(rows().map((row) => row.getAttribute('data-test-id'))).toEqual([ + 'downloaded-series-episode-31', + 'downloaded-series-episode-33', + ]); + expect(fixture.nativeElement.textContent).toContain('2 episodes'); + expect(fixture.nativeElement.textContent).not.toContain('S01E03'); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts new file mode 100644 index 000000000..74f0f11dc --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.ts @@ -0,0 +1,99 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import type { DownloadItem } from '@iptvnator/services'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { + DownloadItemAction, + DownloadItemActionType, +} from './download-actions'; +import type { DownloadSeriesCardViewModel } from './download-library.viewmodel'; + +const EMPTY_PENDING_IDS: ReadonlySet = new Set(); + +@Component({ + selector: 'app-downloaded-series-dialog', + standalone: true, + imports: [ + MatButtonModule, + MatDialogModule, + MatIconModule, + MatTooltipModule, + TranslatePipe, + ], + templateUrl: './downloaded-series-dialog.component.html', + styleUrl: './downloaded-series-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadedSeriesDialogComponent { + readonly group = inject(MAT_DIALOG_DATA); + readonly members = input<() => readonly DownloadItem[]>( + () => this.group.members + ); + readonly pendingIds = input<() => ReadonlySet>( + () => EMPTY_PENDING_IDS + ); + readonly itemAction = output(); + protected readonly currentMembers = computed(() => this.members()()); + + protected emitAction( + type: DownloadItemActionType, + item: DownloadItem + ): void { + if (!this.isPending(item)) { + this.itemAction.emit({ type, item }); + } + } + + protected isPending(item: DownloadItem): boolean { + return this.pendingIds()().has(item.id); + } + + protected episodeCountKey(count: number): string { + return count === 1 + ? 'DOWNLOADS.EPISODE_COUNT_ONE' + : 'DOWNLOADS.EPISODE_COUNT_OTHER'; + } + + protected episodeLabel(item: DownloadItem): string { + if ( + !Number.isSafeInteger(item.seasonNumber) || + (item.seasonNumber ?? -1) < 0 || + !Number.isSafeInteger(item.episodeNumber) || + (item.episodeNumber ?? -1) < 0 + ) { + return ''; + } + return `S${String(item.seasonNumber).padStart(2, '0')}E${String( + item.episodeNumber + ).padStart(2, '0')}`; + } + + protected episodeIdentity(item: DownloadItem): string { + const label = this.episodeLabel(item); + return label ? `${label} — ${item.title}` : item.title; + } + + protected formatBytes(bytes: number | undefined): string { + if (!Number.isFinite(bytes) || (bytes ?? 0) <= 0) { + return '0 B'; + } + const value = bytes as number; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min( + Math.floor(Math.log(value) / Math.log(1024)), + units.length - 1 + ); + const normalized = value / 1024 ** exponent; + return `${normalized >= 10 ? normalized.toFixed(0) : normalized.toFixed(1)} ${units[exponent]}`; + } +} diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index 461e43fbf..b9eff4737 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -1,99 +1,206 @@ -
-
-
-
-
-

{{ 'DOWNLOADS.TITLE' | translate }}

- @if (activeCount() > 0) { - {{ - activeCount() - }} - } -
-
-
- @if (isAvailable()) { -
+
+
+
+

+ {{ 'DOWNLOADS.TITLE' | translate }} +

+ @if (model().activeCount > 0) { + - folder_open - - {{ - downloadFolder() || - ('DOWNLOADS.NO_FOLDER' | translate) - }} - -
- - @if (hasClearableDownloads()) { - - } + {{ + 'DOWNLOADS.ACTIVE_COUNT' + | translate: { count: model().activeCount } + }} + }
-
+ @if (isAvailable()) { +
+ {{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} + {{ formatBytes(model().trackedBytes) }} +
+ } +
+ + @if (isAvailable()) { +
+
+ + @if (downloadFolder(); as folder) { + + @if (downloadFolderParts().prefix) { + + } + + + } @else { + {{ 'DOWNLOADS.NO_FOLDER' | translate }} + } +
+ + @if (model().hasClearable) { + + } +
+ } +
+ + @if ( + isAvailable() && + playlistsLoaded() && + (!hasNoPlaylists() || hasScopedDownloads()) + ) { + + } + +
@if (!isAvailable()) { -
- desktop_access_disabled -

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

- {{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} +
+ +

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

+

{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}

} @else if (!playlistsLoaded()) {
-

- {{ 'CHANNELS.LOADING' | translate }} -

+ {{ + 'CHANNELS.LOADING' | translate + }} + @for (slot of skeletonItems; track slot) { + + } - } @else if (hasNoPlaylists()) { + } @else if (showDownloadSkeleton()) { +
+ {{ + 'CHANNELS.LOADING' | translate + }} + @for (slot of skeletonItems; track slot) { + + } + +
+ } @else if (hasNoPlaylists() && !hasScopedDownloads()) { - } @else if (showDownloadSkeleton()) { -
-

- {{ 'CHANNELS.LOADING' | translate }} -

- -
} @else if (!hasScopedDownloads()) { - } @else if (filteredDownloads().length === 0) { -
- search_off -

{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}

- {{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }} + } @else if (!hasVisibleDownloads()) { +
+ +

{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}

+

{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}

} @else { -
-
- @for (item of filteredDownloads(); track item.id) { -
-
- @if (hasPoster(item)) { - - } @else { - {{ - getPosterPlaceholderIcon(item) - }} - } -
- -
-
- - {{ item.title }} - - @if (item.contentType === 'episode') { - - {{ formatEpisodeLabel(item) }} - - } -
- -
- - {{ - getStatusIcon(item.status) - }} - {{ - 'DOWNLOADS.STATUS.' + - item.status.toUpperCase() - | translate - }} - - - @if ( - item.status === 'downloading' || - item.status === 'paused' || - item.status === 'completed' - ) { - - {{ - formatBytes( - item.bytesDownloaded - ) - }} - @if (item.totalBytes) { - / - {{ - formatBytes(item.totalBytes) - }} - } - - } - - @if (item.errorMessage) { - - {{ item.errorMessage }} - - } -
- - @if (item.status === 'downloading') { - - } @else if ( - item.status === 'paused' && item.totalBytes - ) { - - } - -
- - @switch (item.status) { - @case ('queued') { - - - } - @case ('downloading') { - - - } - @case ('paused') { - - - - } - @case ('completed') { - - - - } - @case ('failed') { - - - } - @case ('canceled') { - - - } - } -
-
-
- } -
-
+ + } -
-
+
+ diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.scss b/libs/portal/downloads/feature/src/lib/downloads.component.scss index a0b21fd79..6595374c3 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloads.component.scss @@ -1,586 +1,451 @@ :host { - width: 100%; display: flex; + width: 100%; + height: 100%; min-width: 0; min-height: 0; -} - -%downloads-skeleton-shimmer { - position: relative; overflow: hidden; - background: color-mix( - in srgb, - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%, - var(--app-body-color, var(--mat-sys-on-surface-variant)) 44% - ); - - &::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 100deg, - transparent 8%, - color-mix( - in srgb, - var(--app-heading-color, var(--mat-sys-on-surface)) 18%, - transparent - ) - 44%, - transparent 74% - ); - transform: translateX(-120%); - animation: downloads-skeleton-shimmer 1.15s ease-in-out infinite; - } -} - -.downloads-layout { - display: flex; - flex: 1; - width: 100%; - min-width: 0; - min-height: 0; } .downloads { - flex: 1; display: flex; + flex: 1; flex-direction: column; height: 100%; min-width: 0; min-height: 0; overflow: hidden; + color: var(--app-body-color, var(--mat-sys-on-surface)); + background: var(--app-content-bg, var(--mat-sys-surface)); + container-type: inline-size; +} - &__header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - padding: 24px 16px 12px; - border-bottom: 1px solid var(--mat-sys-outline-variant); +.downloads__header { + position: relative; + z-index: 2; + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: clamp(18px, 2.2vw, 28px) clamp(16px, 2.6vw, 34px) 16px; + border-bottom: 1px solid + var(--app-separator, var(--mat-sys-outline-variant)); + background: color-mix( + in srgb, + var(--mat-sys-surface) 94%, + var(--mat-sys-surface-container) 6% + ); +} - &-left { - display: flex; - align-items: center; - gap: 16px; +.downloads__heading { + display: grid; + gap: 7px; + min-width: 0; +} - .settings-group__header-text { - display: flex; - align-items: center; - gap: 0.75rem; +.downloads__title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; - h1 { - margin: 0; - font-size: 1.2rem; - font-weight: 650; - letter-spacing: -0.02em; - } - } + h1 { + margin: 0; + font-size: clamp(1.25rem, 1.8vw, 1.6rem); + font-weight: 680; + line-height: 1.15; + letter-spacing: -0.035em; + } +} - .back-btn { - margin-right: 0.5rem; - } - } +.downloads__active-count { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + border-radius: 999px; + color: var(--mat-sys-on-primary-container); + background: var(--mat-sys-primary-container); + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; +} - &-actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; - } +.downloads__tracked { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-size: 0.78rem; + + strong { + color: var(--app-heading-color, var(--mat-sys-on-surface)); + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-variant-numeric: tabular-nums; + font-weight: 650; + } +} + +.downloads__header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; + + button { + flex: none; + } +} + +.downloads__folder { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + max-width: min(32vw, 360px); + padding: 8px 10px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 10px; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + + mat-icon { + width: 18px; + height: 18px; + flex: none; + color: var(--mat-sys-primary); + font-size: 18px; } - &__badge { - background: var(--mat-sys-primary); - color: var(--mat-sys-on-primary); - padding: 0.125rem 0.5rem; - border-radius: 12px; - font-size: 0.75rem; - font-weight: 600; + > span { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + font-size: 0.74rem; } +} - &__folder-inline { - display: inline-flex; - align-items: center; - gap: 8px; - max-width: min(42vw, 460px); - padding: 7px 10px; - background: color-mix( +.downloads__folder-path { + display: flex; + min-width: 0; + max-width: 100%; + white-space: nowrap; +} + +.downloads__folder-prefix, +.downloads__folder-tail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloads__folder-prefix { + min-width: 0; +} + +.downloads__folder-tail { + max-width: 100%; + flex: none; +} + +.downloads__folder-prefix + .downloads__folder-tail { + max-width: 65%; +} + +.downloads__filters { + position: relative; + z-index: 1; + display: flex; + flex: none; + gap: 8px; + padding: 12px clamp(16px, 2.6vw, 34px); + overflow-x: auto; + border-bottom: 1px solid + color-mix( in srgb, - var(--mat-sys-surface-container-high) 86%, + var(--app-separator, var(--mat-sys-outline-variant)) 74%, transparent ); - border: 1px solid var(--mat-sys-outline-variant); - border-radius: 8px; - box-shadow: inset 0 1px 0 - color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent); + scrollbar-width: none; - mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - color: var(--mat-sys-primary); - flex-shrink: 0; - } + &::-webkit-scrollbar { + display: none; + } +} + +.downloads__filter { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 6px 11px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 999px; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + font: inherit; + font-size: 0.79rem; + font-weight: 620; + white-space: nowrap; + cursor: pointer; + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease, + transform 150ms ease; + + &:hover { + color: var(--app-heading-color, var(--mat-sys-on-surface)); + border-color: var(--mat-sys-outline); } - &__folder-inline-path { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, - monospace; - font-size: 0.78rem; - color: var(--mat-sys-on-surface); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &:active { + transform: scale(0.98); } - &__empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - text-align: center; - color: var(--mat-sys-on-surface-variant); - - mat-icon { - font-size: 4rem; - width: 4rem; - height: 4rem; - margin-bottom: 1rem; - opacity: 0.4; - } - - p { - margin: 0 0 0.5rem; - font-size: 1.05rem; - font-weight: 520; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - } - - span { - font-size: 0.875rem; - } + &:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; } - &__list-wrapper { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 0 16px; - container-type: inline-size; - - &--loading { - display: flex; - flex-direction: column; - gap: 12px; - } - } - - &__list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); - gap: 12px; - padding-bottom: 24px; - align-content: start; - - &--skeleton { - pointer-events: none; - } - } - - &__loading-label { - margin: 0; - padding: 2px 4px 0; - font-size: 0.86rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - } - - &__skeleton-item { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - align-items: stretch; - gap: 1rem; - min-width: 0; - min-height: 124px; - padding: 1rem; - border-radius: 14px; - background: color-mix( + &.is-selected { + color: var(--mat-sys-on-primary-container); + border-color: color-mix( in srgb, - var(--app-widget-bg, var(--mat-sys-surface-container-high)) 88%, - var(--mat-sys-surface-container) 12% + var(--mat-sys-primary) 52%, + transparent ); - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - box-shadow: var(--app-widget-shadow, none); - } - - &__skeleton-poster { - @extend %downloads-skeleton-shimmer; - width: 64px; - height: 90px; - border-radius: 10px; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - } - - &__skeleton-info { - display: flex; - flex-direction: column; - gap: 0.55rem; - min-width: 0; - min-height: 90px; - } - - &__skeleton-title { - @extend %downloads-skeleton-shimmer; - width: min(70%, 280px); - height: 13px; - border-radius: 999px; - - &--short { - width: min(48%, 190px); - } - } - - &__skeleton-meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - - span { - @extend %downloads-skeleton-shimmer; - width: 88px; - height: 20px; - border-radius: 999px; - - &:last-child { - width: 132px; - } - } - } - - &__skeleton-progress { - @extend %downloads-skeleton-shimmer; - width: 100%; - height: 6px; - border-radius: 999px; - margin-top: 0.05rem; - } - - &__skeleton-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.35rem; - margin-top: auto; - padding-top: 0.65rem; - border-top: 1px solid - color-mix( - in srgb, - var(--app-separator, var(--mat-sys-outline-variant)) 72%, - transparent - ); - - span { - @extend %downloads-skeleton-shimmer; - width: 32px; - height: 32px; - border-radius: 9px; - } - } - - &__item { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - align-items: stretch; - gap: 1rem; - padding: 1rem; - min-width: 0; - background: var( - --app-widget-bg, - var(--mat-sys-surface-container-high) - ); - border-radius: 14px; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - box-shadow: var(--app-widget-shadow, none); - transition: - background 0.18s ease, - border-color 0.18s ease, - box-shadow 0.2s ease, - transform 0.2s ease; - - &--clickable { - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--mat-sys-primary); - outline-offset: 2px; - } - } - - &:hover { - background: var(--app-selection-surface); - border-color: color-mix( - in srgb, - var(--app-selection-border) 72%, - transparent - ); - } - - &--unavailable { - cursor: default; - } - - &-poster { - width: 64px; - height: 90px; - border-radius: 10px; - overflow: hidden; - flex-shrink: 0; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - mat-icon { - font-size: 2rem; - width: 2rem; - height: 2rem; - color: var(--mat-sys-on-surface-variant); - opacity: 0.5; - } - } - - &-info { - flex: 1; - min-width: 0; - min-height: 90px; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - &-title { - font-size: 1.05rem; - font-weight: 600; - color: var(--mat-sys-on-surface); - display: flex; - align-items: flex-start; - gap: 0.5rem; - min-width: 0; - } - - &-title-text { - min-width: 0; - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - line-height: 1.3; - } - - &-meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - font-size: 0.85rem; - color: var(--mat-sys-on-surface-variant); - } - - &-status { - display: flex; - align-items: center; - gap: 0.35rem; - font-weight: 500; - padding: 2px 8px; - border-radius: 12px; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); - - mat-icon { - font-size: 1.1rem; - width: 1.1rem; - height: 1.1rem; - } - - &.status-downloading { - color: #2196f3; - background: color-mix(in srgb, #2196f3 12%, transparent); - } - - &.status-completed { - color: #4caf50; - background: color-mix(in srgb, #4caf50 12%, transparent); - } - - &.status-failed { - color: var(--mat-sys-error); - background: color-mix( - in srgb, - var(--mat-sys-error) 12%, - transparent - ); - } - - &.status-queued { - color: var(--mat-sys-on-surface-variant); - } - - &.status-paused { - color: #f59e0b; - background: color-mix(in srgb, #f59e0b 14%, transparent); - } - } - - &-size { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, - monospace; - background: transparent; - padding: 2px 8px; - border-radius: 12px; - } - - &-error { - color: var(--mat-sys-error); - font-size: 0.8rem; - background: color-mix( - in srgb, - var(--mat-sys-error) 12%, - transparent - ); - padding: 2px 8px; - border-radius: 12px; - } - - &-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.2rem; - margin-top: auto; - padding-top: 0.65rem; - border-top: 1px solid - color-mix( - in srgb, - var(--app-separator, var(--mat-sys-outline-variant)) 72%, - transparent - ); - - button { - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0; - border-radius: 9px; - --mdc-icon-button-state-layer-size: 32px; - transition: - background-color 0.15s ease, - transform 0.15s ease, - opacity 0.15s ease; - opacity: 0.86; - - &:hover { - background: var(--app-selection-surface); - transform: translateY(-1px); - opacity: 1; - } - } - - mat-icon { - width: 18px; - height: 18px; - font-size: 18px; - } - } - - mat-progress-bar { - margin-top: 0.15rem; - border-radius: 4px; - } - } - - &__episode-badge { - background: var(--mat-sys-primary); - color: var(--mat-sys-on-primary); - padding: 0.125rem 0.5rem; - border-radius: 6px; - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; + background: var(--mat-sys-primary-container); } } -.sidebar-header h2 { - margin: 0; +.downloads__filter-count { + display: inline-grid; + min-width: 19px; + min-height: 19px; + place-items: center; + padding: 0 5px; + border-radius: 999px; + color: inherit; + background: color-mix(in srgb, currentColor 10%, transparent); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; } -@media (max-width: 980px) { - .downloads { - &__header { - align-items: flex-start; - gap: 12px; - } +.downloads__content { + display: flex; + flex: 1; + flex-direction: column; + gap: clamp(24px, 3vw, 38px); + min-width: 0; + min-height: 0; + padding: clamp(18px, 2.5vw, 32px) clamp(16px, 2.6vw, 34px) + clamp(28px, 4vw, 48px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} - &__header-actions { - width: 100%; - justify-content: flex-start; - } +.downloads__message { + display: grid; + flex: 1; + place-content: center; + justify-items: center; + min-height: 260px; + padding: 32px; + text-align: center; - &__folder-inline { - max-width: 100%; - } + mat-icon { + width: 46px; + height: 46px; + margin-bottom: 12px; + color: var(--mat-sys-primary); + font-size: 46px; + opacity: 0.72; + } - &__list { - grid-template-columns: 1fr; - } + h2 { + margin: 0; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + font-size: 1rem; + font-weight: 670; + letter-spacing: -0.015em; + } + + p { + max-width: 460px; + margin: 7px 0 0; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-size: 0.86rem; + line-height: 1.5; + } + + &--compact { + min-height: 220px; + } +} + +.downloads__skeleton { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 10px; +} + +.downloads__skeleton-row { + display: grid; + grid-template-columns: 46px minmax(0, 1fr) 32px; + align-items: center; + gap: 13px; + min-height: 78px; + padding: 11px 13px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 12px; + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); +} + +.downloads__skeleton-poster, +.downloads__skeleton-copy, +.downloads__skeleton-action, +.downloads__skeleton-heading, +.downloads__skeleton-card-artwork, +.downloads__skeleton-card-copy { + position: relative; + display: block; + overflow: hidden; + border-radius: 8px; + background: var( + --app-widget-header-bg, + var(--mat-sys-surface-container-high) + ); + + &::after { + position: absolute; + inset: 0; + background: linear-gradient( + 105deg, + transparent 20%, + color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent) 48%, + transparent 76% + ); + transform: translateX(-110%); + animation: downloads-shimmer 1.2s ease-in-out infinite; + content: ''; + } +} + +.downloads__skeleton-poster { + width: 46px; + height: 62px; +} + +.downloads__skeleton-copy { + width: min(78%, 260px); + height: 12px; +} + +.downloads__skeleton-action { + width: 32px; + height: 32px; +} + +.downloads__skeleton-library { + display: grid; + grid-column: 1 / -1; + gap: 12px; + margin-top: 18px; +} + +.downloads__skeleton-heading { + width: 142px; + height: 18px; +} + +.downloads__skeleton-cards { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(min(100%, var(--cover-grid-min-width, 148px)), 1fr) + ); + gap: var(--cover-gap, 16px); +} + +.downloads__skeleton-card { + display: grid; + gap: 10px; + min-width: 0; + padding-bottom: 12px; +} + +.downloads__skeleton-card-artwork { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 14px; +} + +.downloads__skeleton-card-copy { + width: 76%; + height: 12px; + margin-inline: 8px; +} + +.downloads__sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@keyframes downloads-shimmer { + to { + transform: translateX(110%); } } @container (max-width: 760px) { - .downloads__item { - align-items: start; + .downloads__header { + align-items: stretch; + flex-direction: column; + gap: 14px; } - .downloads__skeleton-item { - align-items: start; + .downloads__header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .downloads__folder { + max-width: 100%; + flex: 1 1 240px; + } +} + +@container (max-width: 480px) { + .downloads__header-actions button { + flex: 1 1 auto; + } + + .downloads__content { + padding-inline: 12px; + } + + .downloads__filters { + padding-inline: 12px; } } @media (prefers-reduced-motion: reduce) { - .downloads__skeleton-poster, - .downloads__skeleton-title, - .downloads__skeleton-meta span, - .downloads__skeleton-progress, - .downloads__skeleton-actions span { - &::after { - animation: none; - transform: none; - } + .downloads__filter { + transition: none; } -} -@keyframes downloads-skeleton-shimmer { - 100% { - transform: translateX(120%); + .downloads__skeleton-poster::after, + .downloads__skeleton-copy::after, + .downloads__skeleton-action::after { + animation: none; } } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts new file mode 100644 index 000000000..6e15424fa --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -0,0 +1,947 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { computed, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + ActivatedRoute, + convertToParamMap, + type ParamMap, + Router, +} from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + type DownloadItem, + DownloadsService, + PlaylistsService, +} from '@iptvnator/services'; +import { + PORTAL_SHELL_ACTIONS, + PortalCollectionContextService, +} from '@iptvnator/portal/shared/util'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { DialogService } from '@iptvnator/ui/components'; +import { BehaviorSubject, type Observable, Subject } from 'rxjs'; +import { DownloadLibraryNavigationService } from './download-library-navigation.service'; +import type { DownloadActionResult } from './download-actions'; +import { DownloadManagerActionsService } from './download-manager-actions.service'; +import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel'; +import { DownloadsComponent } from './downloads.component'; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +interface ConfirmConfig { + readonly title: string; + readonly message: string; + readonly onConfirm: () => Promise | void; +} + +interface ExpectedDownloadsComponent { + readonly model: () => { + readonly active: readonly { readonly item: DownloadItem }[]; + readonly activeCount: number; + readonly trackedBytes: number; + }; + readonly pendingIds: () => ReadonlySet; + clearFinished(): void; + openDownloadedSeries(group: DownloadSeriesCardViewModel): void; + openOfflineDetail(item: DownloadItem): void; + openInLibrary(item: DownloadItem): Promise; + runAction(action: { + readonly type: string; + readonly item: DownloadItem; + }): Promise | void; + setFilter(filter: string): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1000 + id, + contentType: 'vod', + title: `Download ${id}`, + url: `https://example.com/${id}.mp4`, + status: 'completed', + fileAvailability: 'available', + bytesDownloaded: id * 10, + filePath: `/downloads/${id}.mp4`, + createdAt: `2026-07-${String(id).padStart(2, '0')}T12:00:00Z`, + ...overrides, + }; +} + +function playlist(id: string, title: string): Playlist { + return { _id: id, title } as Playlist; +} + +describe('DownloadsComponent', () => { + let fixture: ComponentFixture; + let component: ExpectedDownloadsComponent; + let downloads: ReturnType>; + let routeParams: BehaviorSubject>; + let queryParams: BehaviorSubject>; + let activatedRoute: { + readonly paramMap: Observable; + readonly queryParamMap: Observable; + readonly snapshot: { + readonly params: Record; + readonly paramMap: ReturnType; + readonly queryParamMap: ReturnType; + }; + }; + let router: { url: string; navigate: jest.Mock }; + let playlistItems: BehaviorSubject; + let collectionContext: PortalCollectionContextService; + let dialogConfigs: ConfirmConfig[]; + let dialogActionListener: + ((action: { type: string; item: DownloadItem }) => void) | undefined; + let dialogClosed: Subject; + let dialogActionUnsubscribe: jest.Mock; + let dialogSetInput: jest.Mock; + let downloadsService: { + readonly downloads: typeof downloads; + readonly downloadFolder: ReturnType>; + readonly isAvailable: ReturnType>; + readonly isLoadingDownloads: ReturnType>; + readonly hasLoadedDownloads: ReturnType>; + readonly activeCount: ReturnType>; + loadDownloads: jest.Mock; + cancelDownload: jest.Mock; + pauseDownload: jest.Mock; + redownloadMissing: jest.Mock; + resumeDownload: jest.Mock; + retryDownload: jest.Mock; + removeDownload: jest.Mock; + playDownload: jest.Mock; + revealFile: jest.Mock; + selectFolder: jest.Mock; + clearCompleted: jest.Mock; + formatBytes: jest.Mock; + }; + let navigation: { + canOpen: jest.Mock; + open: jest.Mock; + }; + let snackBar: { open: jest.Mock }; + let matDialog: { open: jest.Mock }; + + const success = async () => ({ success: true }); + + beforeEach(async () => { + downloads = signal([]); + routeParams = new BehaviorSubject(convertToParamMap({})); + queryParams = new BehaviorSubject(convertToParamMap({})); + activatedRoute = { + paramMap: routeParams.asObservable(), + queryParamMap: queryParams.asObservable(), + snapshot: { + params: {}, + paramMap: convertToParamMap({}), + queryParamMap: convertToParamMap({}), + }, + }; + router = { + url: '/workspace/downloads?q=signal', + navigate: jest.fn(async () => true), + }; + playlistItems = new BehaviorSubject([ + playlist('playlist-a', 'Alpha source'), + playlist('playlist-b', 'Beta source'), + ]); + dialogConfigs = []; + dialogClosed = new Subject(); + dialogActionUnsubscribe = jest.fn(); + dialogSetInput = jest.fn(); + dialogActionListener = undefined; + snackBar = { open: jest.fn() }; + navigation = { + canOpen: jest.fn(() => true), + open: jest.fn(async () => true), + }; + matDialog = { + open: jest.fn(() => ({ + componentRef: { setInput: dialogSetInput }, + componentInstance: { + itemAction: { + subscribe: jest.fn( + ( + listener: (action: { + type: string; + item: DownloadItem; + }) => void + ) => { + dialogActionListener = listener; + return { + unsubscribe: dialogActionUnsubscribe, + }; + } + ), + }, + }, + afterClosed: () => dialogClosed.asObservable(), + })), + }; + downloadsService = { + downloads, + downloadFolder: signal('/downloads'), + isAvailable: signal(true), + isLoadingDownloads: signal(false), + hasLoadedDownloads: signal(true), + activeCount: computed( + () => + downloads().filter( + ({ status }) => + status === 'queued' || status === 'downloading' + ).length + ), + loadDownloads: jest.fn(async () => undefined), + cancelDownload: jest.fn(success), + pauseDownload: jest.fn(success), + redownloadMissing: jest.fn(success), + resumeDownload: jest.fn(success), + retryDownload: jest.fn(success), + removeDownload: jest.fn(success), + playDownload: jest.fn(success), + revealFile: jest.fn(success), + selectFolder: jest.fn(async () => '/downloads'), + clearCompleted: jest.fn(success), + formatBytes: jest.fn((bytes: number) => `${bytes} B`), + }; + + await TestBed.configureTestingModule({ + imports: [ + DownloadsComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: ActivatedRoute, + useValue: activatedRoute, + }, + { + provide: Router, + useValue: router, + }, + { + provide: DownloadsService, + useValue: downloadsService, + }, + { + provide: PlaylistsService, + useValue: { + getAllPlaylists: () => playlistItems.asObservable(), + }, + }, + { + provide: DialogService, + useValue: { + openConfirmDialog: (config: ConfirmConfig) => + dialogConfigs.push(config), + }, + }, + { provide: MatDialog, useValue: matDialog }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: PORTAL_SHELL_ACTIONS, + useValue: { openAddPlaylistDialog: jest.fn() }, + }, + PortalCollectionContextService, + ], + }) + .overrideComponent(DownloadsComponent, { + set: { + providers: [ + { + provide: DownloadLibraryNavigationService, + useValue: navigation, + }, + { provide: MatDialog, useValue: matDialog }, + DownloadManagerActionsService, + ], + }, + }) + .compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + DOWNLOADS: { + TITLE: 'Downloads', + ACTIVE_COUNT: '{{count}} active', + TRACKED_DOWNLOADS: 'Tracked downloads', + CHANGE_FOLDER: 'Change folder', + CLEAR_FINISHED: 'Clear finished', + FILTER: { + LABEL: 'Filter downloads', + ALL: 'All', + MOVIES: 'Movies', + SERIES: 'Series', + IN_PROGRESS: 'In progress', + }, + REMOVE_COMPLETED_DIALOG: { + TITLE: 'Remove from manager?', + MESSAGE: 'The downloaded media file remains on disk.', + }, + REMOVE_PARTIAL_DIALOG: { + TITLE: 'Remove partial download?', + MESSAGE: 'Retained partial download data will be deleted.', + }, + CLEAR_FINISHED_DIALOG: { + TITLE: 'Clear finished downloads?', + MESSAGE: + 'Completed files remain; retained partial data is deleted.', + }, + ACTION_FAILED: 'Action failed', + FILE_NOT_FOUND: 'File not found', + FILE_ACTION_ERROR: 'File action failed', + DOWNLOAD_AGAIN: 'Download again', + STATUS: { + FILE_MISSING: 'File missing', + }, + ARIA: { + DOWNLOAD_AGAIN: 'Download {{title}} again', + }, + SOURCE_PLAYLIST_MISSING: 'Source playlist missing', + URL_COPIED: 'URL copied', + URL_COPY_FAILED: 'URL copy failed', + REMOVE_FROM_MANAGER: 'Remove from manager', + PLAY: 'Play', + }, + CHANNELS: { LOADING: 'Loading' }, + WORKSPACE: { + SHELL: { + GO_TO_SOURCES: 'Go to sources', + GO_TO_DASHBOARD: 'Go to dashboard', + }, + }, + HOME: { PLAYLISTS: { ADD_YOUR_FIRST: 'Add playlist' } }, + }); + translate.use('en'); + + collectionContext = TestBed.inject(PortalCollectionContextService); + fixture = TestBed.createComponent(DownloadsComponent); + component = + fixture.componentInstance as unknown as ExpectedDownloadsComponent; + fixture.detectChanges(); + TestBed.flushEffects(); + }); + + afterEach(() => { + fixture?.destroy(); + dialogClosed.complete(); + }); + + it('loads the authoritative global list without a playlist argument', () => { + expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1); + expect(downloadsService.loadDownloads).toHaveBeenCalledWith(); + }); + + it('keeps the folder tail visible while exposing the complete path', () => { + const folder = '/Users/viewer/Media/Very Long Folder/Downloads'; + downloadsService.downloadFolder.set(folder); + fixture.detectChanges(); + + const path = fixture.nativeElement.querySelector( + '.downloads__folder-path' + ) as HTMLElement; + expect(path.getAttribute('aria-label')).toBe(folder); + expect( + path.querySelector('.downloads__folder-prefix')?.textContent?.trim() + ).toBe('/Users/viewer/Media/Very Long Folder'); + expect( + path.querySelector('.downloads__folder-tail')?.textContent?.trim() + ).toBe('/Downloads'); + expect( + fixture.nativeElement + .querySelector('[data-test-id="downloads-folder"]') + ?.getAttribute('title') + ).toBe(folder); + }); + + it('shapes the loading state as both queue rows and poster cards', () => { + downloadsService.hasLoadedDownloads.set(false); + downloadsService.isLoadingDownloads.set(true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelectorAll('.downloads__skeleton-row') + ).toHaveLength(6); + expect( + fixture.nativeElement.querySelectorAll('.downloads__skeleton-card') + ).toHaveLength(5); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-library-skeleton"]' + ) + ).not.toBeNull(); + }); + + it('uses the shared cover-grid sizing contract for skeleton cards', () => { + const styles = readFileSync( + join(__dirname, 'downloads.component.scss'), + 'utf8' + ); + const skeletonCardsRule = + styles.match(/\.downloads__skeleton-cards\s*\{([^}]*)\}/)?.[1] ?? + ''; + + expect(skeletonCardsRule).toContain( + 'var(--cover-grid-min-width, 148px)' + ); + expect(skeletonCardsRule).toContain('var(--cover-gap, 16px)'); + expect(skeletonCardsRule).not.toContain('150px'); + }); + + it('derives route scope without mutating the global service signal', () => { + const globalItems = [ + download(1, { status: 'downloading' }), + download(2, { + playlistId: 'playlist-b', + status: 'downloading', + }), + ]; + downloads.set(globalItems); + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + fixture.detectChanges(); + + expect(component.model().active.map(({ item }) => item.id)).toEqual([ + 1, + ]); + expect(component.model().activeCount).toBe(1); + expect(downloadsService.activeCount()).toBe(2); + expect(downloads()).toBe(globalItems); + }); + + it('keeps retained offline downloads visible after the last source is removed', () => { + playlistItems.next([]); + downloads.set([ + download(4, { + playlistId: 'removed-playlist', + title: 'Retained offline movie', + }), + ]); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('app-download-library')).not.toBeNull(); + expect(root.querySelector('app-empty-state')).toBeNull(); + expect( + root.querySelector('[data-test-id="downloads-filters"]') + ).not.toBeNull(); + }); + + it('combines the shared category and q search in the pure model', () => { + downloads.set([ + download(1, { + title: 'Needle movie', + status: 'downloading', + }), + download(2, { + contentType: 'episode', + title: 'Needle series - S01E01 - Pilot', + seriesXtreamId: 20, + seasonNumber: 1, + episodeNumber: 1, + status: 'downloading', + }), + download(3, { title: 'Other movie', status: 'downloading' }), + ]); + collectionContext.setCategoryId('series'); + queryParams.next(convertToParamMap({ q: 'needle' })); + fixture.detectChanges(); + + expect(component.model().active.map(({ item }) => item.id)).toEqual([ + 2, + ]); + }); + + it('keeps inline filters synchronized with the shell context', () => { + component.setFilter('in-progress'); + + expect(collectionContext.selectedCategoryId()).toBe('in-progress'); + }); + + it('restores the selected filter from the manager query parameters', () => { + queryParams.next( + convertToParamMap({ + q: 'northwind', + filter: 'series', + }) + ); + TestBed.flushEffects(); + + expect(collectionContext.selectedCategoryId()).toBe('series'); + expect(component.model().active).toEqual([]); + }); + + it('stores filter changes in the current history entry for focused-detail Back', () => { + component.setFilter('movie'); + TestBed.flushEffects(); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: activatedRoute, + queryParams: { filter: 'movie' }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + it('keeps tracked bytes scoped but independent from search and filter', () => { + downloads.set([ + download(1, { bytesDownloaded: 10 }), + download(2, { + playlistId: 'playlist-b', + bytesDownloaded: 100, + }), + download(3, { bytesDownloaded: 20 }), + ]); + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + collectionContext.setCategoryId('series'); + queryParams.next(convertToParamMap({ q: 'missing' })); + fixture.detectChanges(); + + expect(component.model().trackedBytes).toBe(30); + expect(component.model().active).toEqual([]); + }); + + it.each([ + [ + 'completed', + 'Remove from manager?', + 'downloaded media file remains on disk', + ], + [ + 'paused', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + [ + 'failed', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + [ + 'canceled', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + ] as const)( + 'uses truthful %s removal confirmation copy', + async (status, title, message) => { + const item = download(7, { status }); + + component.runAction({ type: 'remove', item }); + + expect(dialogConfigs).toHaveLength(1); + expect(dialogConfigs[0]).toEqual( + expect.objectContaining({ title }) + ); + expect(dialogConfigs[0].message).toContain(message); + expect(downloadsService.removeDownload).not.toHaveBeenCalled(); + + await dialogConfigs[0].onConfirm(); + expect(downloadsService.removeDownload).toHaveBeenCalledWith(7); + } + ); + + it('describes both finalized and retained-partial outcomes when clearing', async () => { + component.clearFinished(); + + expect(dialogConfigs[0]).toEqual( + expect.objectContaining({ + title: 'Clear finished downloads?', + message: + 'Completed files remain; retained partial data is deleted.', + }) + ); + + await dialogConfigs[0].onConfirm(); + expect(downloadsService.clearCompleted).toHaveBeenCalledWith(undefined); + }); + + it('passes playlist scope when clearing a scoped route', async () => { + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + fixture.detectChanges(); + + component.clearFinished(); + await dialogConfigs[0].onConfirm(); + + expect(downloadsService.clearCompleted).toHaveBeenCalledWith( + 'playlist-a' + ); + }); + + it('holds an item pending until the authoritative operation settles', async () => { + const operation = deferred<{ success: boolean }>(); + downloadsService.pauseDownload.mockReturnValueOnce(operation.promise); + const item = download(8, { status: 'downloading' }); + + const action = component.runAction({ type: 'pause', item }); + + expect(component.pendingIds().has(8)).toBe(true); + operation.resolve({ success: true }); + await expect(action).resolves.toBe('success'); + expect(component.pendingIds().has(8)).toBe(false); + }); + + it('does not dispatch the same pending item twice', async () => { + const operation = deferred<{ success: boolean }>(); + downloadsService.pauseDownload.mockReturnValue(operation.promise); + const item = download(9, { status: 'downloading' }); + + const first = component.runAction({ type: 'pause', item }); + const second = component.runAction({ type: 'pause', item }); + + expect(downloadsService.pauseDownload).toHaveBeenCalledTimes(1); + await expect(second).resolves.toBe('ignored'); + operation.resolve({ success: true }); + await expect(first).resolves.toBe('success'); + }); + + it('reports operation failures through the existing snackbar path', async () => { + downloadsService.retryDownload.mockResolvedValueOnce({ + success: false, + error: 'network offline', + }); + + await expect( + component.runAction({ + type: 'retry', + item: download(10, { status: 'failed' }), + }) + ).resolves.toBe('failed'); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Action failed: network offline', + undefined, + expect.objectContaining({ duration: 4000 }) + ); + }); + + it('moves a recovered missing file from attention into the active queue', async () => { + const recovery = deferred<{ success: boolean }>(); + const item = download(16, { + fileAvailability: 'missing', + title: 'Missing movie', + }); + downloadsService.redownloadMissing.mockReturnValueOnce( + recovery.promise + ); + downloads.set([item]); + fixture.detectChanges(); + + const attention = fixture.nativeElement.querySelector( + '[data-test-id="downloads-attention-section"]' + ); + const recover = attention?.querySelector( + '[data-test-action="redownload"]' + ); + expect(attention?.textContent).toContain('File missing'); + expect(recover).not.toBeNull(); + + recover?.click(); + fixture.detectChanges(); + expect(downloadsService.redownloadMissing).toHaveBeenCalledWith(16); + expect(downloadsService.redownloadMissing.mock.calls[0]).toHaveLength( + 1 + ); + expect(component.pendingIds().has(16)).toBe(true); + + recovery.resolve({ success: true }); + await fixture.whenStable(); + downloads.set([ + { + ...item, + fileAvailability: 'not-applicable', + status: 'queued', + }, + ]); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-attention-section"]' + ) + ).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-active-section"]' + )?.textContent + ).toContain('Missing movie'); + expect(component.pendingIds().has(16)).toBe(false); + }); + + it('refreshes a newly missing file before clearing pending state', async () => { + const reload = deferred(); + downloadsService.playDownload.mockResolvedValueOnce({ + error: 'File not found', + success: false, + }); + downloadsService.loadDownloads.mockReturnValueOnce(reload.promise); + const item = download(17); + + const action = component.runAction({ type: 'play', item }); + await Promise.resolve(); + await Promise.resolve(); + + expect(downloadsService.loadDownloads).toHaveBeenCalled(); + expect(component.pendingIds().has(17)).toBe(true); + expect(snackBar.open).not.toHaveBeenCalled(); + + reload.resolve(); + await expect(action).resolves.toBe('file-missing'); + + expect(component.pendingIds().has(17)).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'File not found', + undefined, + expect.objectContaining({ duration: 3000 }) + ); + }); + + it('reports a missing file path as a typed file-missing result', async () => { + await expect( + component.runAction({ + type: 'reveal', + item: download(18, { filePath: undefined }), + }) + ).resolves.toBe('file-missing'); + + expect(downloadsService.revealFile).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'File not found', + undefined, + expect.objectContaining({ duration: 3000 }) + ); + }); + + it('routes dialog episode actions through the shared dispatcher and cleans up', async () => { + const item = download(11, { + contentType: 'episode', + episodeNumber: 1, + seasonNumber: 1, + seriesXtreamId: 44, + title: 'Series - S01E01 - Pilot', + }); + const group = { + kind: 'series', + key: 'series:playlist-a:44', + newestTimestamp: 1, + sourceName: 'Alpha source', + trackedBytes: 10, + representative: item, + members: [item], + seriesXtreamId: 44, + title: 'Series', + } satisfies DownloadSeriesCardViewModel; + downloads.set([item]); + + component.openDownloadedSeries(group); + dialogActionListener?.({ type: 'play', item }); + await fixture.whenStable(); + + expect(matDialog.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: group, + maxWidth: 'min(720px, calc(100vw - 32px))', + width: 'min(720px, calc(100vw - 32px))', + }) + ); + expect(dialogSetInput).toHaveBeenCalledWith( + 'pendingIds', + component.pendingIds + ); + const membersInput = dialogSetInput.mock.calls.find( + ([name]) => name === 'members' + )?.[1] as (() => readonly DownloadItem[]) | undefined; + expect(membersInput?.()).toEqual([item]); + expect(downloadsService.playDownload).toHaveBeenCalledWith( + item.filePath + ); + + downloads.set([]); + expect(membersInput?.()).toEqual([]); + + dialogClosed.next(); + expect(dialogActionUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('uses the isolated navigation seam and reports a rejected route', async () => { + const item = download(12); + navigation.open.mockResolvedValueOnce(false); + + await component.openInLibrary(item); + + expect(navigation.canOpen).toHaveBeenCalledWith( + item, + new Set(['playlist-a', 'playlist-b']) + ); + expect(navigation.open).toHaveBeenCalledWith(item); + expect(snackBar.open).toHaveBeenCalledWith( + 'Action failed', + undefined, + expect.objectContaining({ duration: 4000 }) + ); + }); + + it('opens removed-source movie artwork in its offline detail while explicit Play stays local', async () => { + const item = download(17, { + playlistId: 'removed-playlist', + title: 'Signal', + filePath: '/downloads/signal.mp4', + }); + downloads.set([item]); + await fixture.whenStable(); + + const artworkButton = fixture.nativeElement.querySelector( + '.download-library__artwork-button' + ) as HTMLButtonElement; + expect(artworkButton).toBeTruthy(); + + artworkButton.click(); + await fixture.whenStable(); + + expect(router.navigate).toHaveBeenCalledWith(['17'], { + relativeTo: activatedRoute, + state: { returnUrl: '/workspace/downloads?q=signal' }, + }); + expect(navigation.canOpen).not.toHaveBeenCalled(); + expect(navigation.open).not.toHaveBeenCalled(); + expect(downloadsService.playDownload).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + const playButton = fixture.nativeElement.querySelector( + '.download-library__actions button[aria-label="Play: Signal"]' + ) as HTMLButtonElement; + expect(playButton).toBeTruthy(); + + playButton.click(); + await fixture.whenStable(); + + expect(downloadsService.playDownload).toHaveBeenCalledWith( + item.filePath + ); + expect(router.navigate).not.toHaveBeenCalled(); + expect(navigation.open).not.toHaveBeenCalled(); + }); + + it('opens a grouped-series representative relative to the Xtream downloads route', async () => { + const older = download(18, { + contentType: 'episode', + createdAt: '2026-07-18T12:00:00Z', + episodeNumber: 1, + seasonNumber: 1, + seriesXtreamId: 77, + title: 'Northwind - S01E01 - Arrival', + }); + const representative = download(19, { + contentType: 'episode', + createdAt: '2026-07-19T12:00:00Z', + episodeNumber: 2, + seasonNumber: 1, + seriesXtreamId: 77, + title: 'Northwind - S01E02 - Signal', + }); + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + router.url = + '/workspace/xtreams/playlist-a/downloads?q=northwind&filter=series'; + downloads.set([older, representative]); + await fixture.whenStable(); + + const seriesArtwork = fixture.nativeElement.querySelector( + '[data-test-id="download-library-series-open"].download-library__artwork-button' + ) as HTMLButtonElement; + expect(seriesArtwork).toBeTruthy(); + + seriesArtwork.click(); + await fixture.whenStable(); + + expect(router.navigate).toHaveBeenCalledWith(['19'], { + relativeTo: activatedRoute, + state: { + returnUrl: + '/workspace/xtreams/playlist-a/downloads?q=northwind&filter=series', + }, + }); + expect(navigation.open).not.toHaveBeenCalled(); + }); + + it('keeps offline detail navigation relative to the Stalker downloads route', () => { + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + router.url = '/workspace/stalker/playlist-a/downloads?q=signal'; + + component.openOfflineDetail(download(22)); + + expect(router.navigate).toHaveBeenCalledWith(['22'], { + relativeTo: activatedRoute, + state: { + returnUrl: '/workspace/stalker/playlist-a/downloads?q=signal', + }, + }); + }); + + it('blocks only the exact pending download from opening offline detail', async () => { + const operation = deferred<{ success: boolean }>(); + const pending = download(20, { status: 'downloading' }); + const ready = download(21); + downloadsService.pauseDownload.mockReturnValueOnce(operation.promise); + + const action = component.runAction({ type: 'pause', item: pending }); + component.openOfflineDetail(pending); + component.openOfflineDetail(ready); + + expect(router.navigate).toHaveBeenCalledTimes(1); + expect(router.navigate).toHaveBeenCalledWith(['21'], { + relativeTo: activatedRoute, + state: { returnUrl: '/workspace/downloads?q=signal' }, + }); + + operation.resolve({ success: true }); + await action; + }); + + it('does not navigate an item whose source playlist is missing', async () => { + const item = download(13, { playlistId: 'removed-playlist' }); + navigation.canOpen.mockReturnValueOnce(false); + + await component.openInLibrary(item); + + expect(navigation.open).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'Source playlist missing', + undefined, + expect.objectContaining({ duration: 3000 }) + ); + }); + + it('renders one scroll owner with fixed controls and both child surfaces', () => { + downloads.set([download(14, { status: 'downloading' }), download(15)]); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelectorAll('.downloads__content')).toHaveLength(1); + expect( + root.querySelector('[data-test-id="downloads-filters"]') + ).toBeTruthy(); + expect(root.querySelector('app-download-queue')).toBeTruthy(); + expect(root.querySelector('app-download-library')).toBeTruthy(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 9af28ecca..6f44bb6fd 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -1,59 +1,72 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, computed, effect, inject, - signal, + untracked, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { DialogService } from '@iptvnator/ui/components'; -import { firstValueFrom, map, startWith } from 'rxjs'; import { - DatabaseService, type DownloadItem, DownloadsService, PlaylistsService, } from '@iptvnator/services'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; -import { queryParamSignal } from '@iptvnator/portal/shared/util'; -import { createPortalCollectionContext } from '@iptvnator/portal/shared/util'; import { - buildStandardCollectionCategories, - filterCollectionBucket, PORTAL_SHELL_ACTIONS, + PortalCollectionContextService, + buildStandardCollectionCategories, + createPortalCollectionContext, + queryParamSignal, } from '@iptvnator/portal/shared/util'; -import { PortalCollectionContextService } from '@iptvnator/portal/shared/util'; -import { Playlist } from '@iptvnator/shared/interfaces'; +import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces'; +import { map, startWith, take } from 'rxjs'; +import type { + DownloadActionResult, + DownloadItemAction, +} from './download-actions'; +import { DownloadLibraryNavigationService } from './download-library-navigation.service'; +import { DownloadLibraryComponent } from './download-library.component'; +import { DownloadManagerActionsService } from './download-manager-actions.service'; +import { + type DownloadSeriesCardViewModel, + buildDownloadManagerViewModel, + normalizeDownloadFilter, +} from './download-manager.viewmodel'; +import { DownloadQueueComponent } from './download-queue.component'; +import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component'; -type PortalSource = 'xtream' | 'stalker'; -const DOWNLOAD_COLLECTION_LABELS = { - all: 'All', - movie: 'Movies', - live: 'Live TV', - series: 'Series', -}; +const FILTER_KEYS = { + all: 'DOWNLOADS.FILTER.ALL', + movie: 'DOWNLOADS.FILTER.MOVIES', + series: 'DOWNLOADS.FILTER.SERIES', + inProgress: 'DOWNLOADS.FILTER.IN_PROGRESS', +} as const; @Component({ selector: 'app-downloads', templateUrl: './downloads.component.html', - styleUrls: [ - './downloads.component.scss', - '../../../../shared/ui/src/lib/styles/portal-sidebar.scss', - ], + styleUrl: './downloads.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + DownloadLibraryNavigationService, + DownloadManagerActionsService, + ], imports: [ + DownloadLibraryComponent, + DownloadQueueComponent, EmptyStateComponent, MatButtonModule, + MatDialogModule, MatIcon, - MatProgressBarModule, MatTooltip, TranslatePipe, ], @@ -61,44 +74,123 @@ const DOWNLOAD_COLLECTION_LABELS = { export class DownloadsComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); private readonly collectionCtx = inject(PortalCollectionContextService); - private readonly dialogService = inject(DialogService); + private readonly dialog = inject(MatDialog); + private readonly navigation = inject(DownloadLibraryNavigationService); + private readonly actions = inject(DownloadManagerActionsService); private readonly translate = inject(TranslateService); - private readonly snackBar = inject(MatSnackBar); private readonly shellActions = inject(PORTAL_SHELL_ACTIONS); + private readonly destroyRef = inject(DestroyRef); readonly downloadsService = inject(DownloadsService); - readonly downloads = this.downloadsService.downloads; + readonly pendingIds = this.actions.pendingIds; + readonly isClearing = this.actions.isClearing; readonly downloadFolder = this.downloadsService.downloadFolder; + readonly downloadFolderParts = computed(() => { + const path = this.downloadFolder(); + const separatorIndex = Math.max( + path.lastIndexOf('/'), + path.lastIndexOf('\\') + ); + return separatorIndex < 0 + ? { prefix: '', tail: path } + : { + prefix: path.slice(0, separatorIndex), + tail: path.slice(separatorIndex), + }; + }); readonly isAvailable = this.downloadsService.isAvailable; readonly isLoadingDownloads = this.downloadsService.isLoadingDownloads; readonly hasLoadedDownloads = this.downloadsService.hasLoadedDownloads; - readonly activeCount = this.downloadsService.activeCount; readonly playlistId = toSignal( this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), { initialValue: this.route.snapshot.params['id'] ?? '' } ); readonly searchTerm = queryParamSignal(this.route, 'q', (value) => - (value ?? '').trim().toLowerCase() + (value ?? '').trim() + ); + readonly routeFilter = queryParamSignal(this.route, 'filter', (value) => + normalizeDownloadFilter(value) ); readonly playlists = toSignal( this.playlistsService.getAllPlaylists().pipe(startWith(null)), - { - initialValue: null as Playlist[] | null, - } + { initialValue: null as Playlist[] | null } + ); + readonly languageChange = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } ); - readonly playlistsLoaded = computed(() => this.playlists() !== null); readonly playlistItems = computed(() => this.playlists() ?? []); + readonly playlistsLoaded = computed(() => this.playlists() !== null); readonly hasNoPlaylists = computed( () => this.playlistsLoaded() && this.playlistItems().length === 0 ); - readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); - readonly skeletonActionSlots = Array.from( - { length: 4 }, - (_, index) => index + readonly availablePlaylistIds = computed( + () => new Set(this.playlistItems().map(({ _id }) => _id)) ); + readonly selectedCategoryId = this.collectionCtx.selectedCategoryId; + readonly model = computed(() => + buildDownloadManagerViewModel({ + downloads: this.downloadsService.downloads(), + playlists: this.playlistItems(), + scopePlaylistId: this.playlistId() || undefined, + filter: this.selectedCategoryId(), + searchTerm: this.searchTerm(), + }) + ); + readonly categories = computed(() => this.buildCategories()); + readonly collectionContext = createPortalCollectionContext({ + ctx: this.collectionCtx, + categories: this.categories, + }); + readonly hasScopedDownloads = computed( + () => this.model().scopedItems.length > 0 + ); + readonly hasVisibleDownloads = computed(() => { + const model = this.model(); + return ( + model.active.length + + model.attention.length + + model.library.length > + 0 + ); + }); + readonly showDownloadSkeleton = computed( + () => + !this.hasScopedDownloads() && + (!this.hasLoadedDownloads() || this.isLoadingDownloads()) + ); + readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); + readonly skeletonCards = Array.from({ length: 5 }, (_, index) => index); + + constructor() { + void this.downloadsService.loadDownloads(); + effect(() => { + const routeFilter = this.routeFilter(); + untracked(() => this.collectionContext.setCategoryId(routeFilter)); + }); + effect(() => { + const selectedFilter = normalizeDownloadFilter( + this.selectedCategoryId() + ); + if (selectedFilter === this.routeFilter()) { + return; + } + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { + filter: selectedFilter === 'all' ? null : selectedFilter, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + } + + setFilter(filter: string): void { + this.collectionContext.setCategoryId(normalizeDownloadFilter(filter)); + } addPlaylist(): void { this.shellActions.openAddPlaylistDialog(); @@ -112,512 +204,96 @@ export class DownloadsComponent { void this.router.navigate(['/workspace', 'dashboard']); } - readonly scopedDownloads = computed(() => { - const playlistId = this.playlistId(); - const downloads = this.downloads(); - return playlistId - ? downloads.filter((item) => item.playlistId === playlistId) - : downloads; - }); - readonly hasScopedDownloads = computed( - () => this.scopedDownloads().length > 0 - ); - readonly showDownloadSkeleton = computed( - () => - !this.hasScopedDownloads() && - (!this.hasLoadedDownloads() || this.isLoadingDownloads()) - ); - - readonly categories = computed(() => { - const downloads = this.scopedDownloads(); - const moviesCount = downloads.filter( - (item) => item.contentType === 'vod' - ).length; - const seriesCount = downloads.filter( - (item) => item.contentType === 'episode' - ).length; - - return buildStandardCollectionCategories({ - labels: DOWNLOAD_COLLECTION_LABELS, - counts: { - all: downloads.length, - movie: moviesCount, - series: seriesCount, - }, - }); - }); - readonly collectionContext = createPortalCollectionContext({ - ctx: this.collectionCtx, - categories: this.categories, - }); - readonly selectedCategoryId = this.collectionContext.selectedCategoryId; - readonly failedPosterKeys = signal>({}); - - /** Filter downloads for current playlist and sort by newest first */ - readonly filteredDownloads = computed(() => { - const term = this.searchTerm(); - const downloads = this.scopedDownloads(); - const filteredByTerm = filterCollectionBucket({ - selectedCategoryId: this.selectedCategoryId(), - allItems: downloads, - buckets: { - movie: downloads.filter((item) => item.contentType === 'vod'), - series: downloads.filter( - (item) => item.contentType === 'episode' - ), - }, - searchTerm: term, - textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`, - }); - - // Sort by createdAt descending (newest first) - return [...filteredByTerm].sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; - }); - }); - readonly hasClearableDownloads = computed(() => - this.scopedDownloads().some( - (item) => - item.status === 'completed' || - item.status === 'failed' || - item.status === 'canceled' - ) - ); - readonly availablePlaylistIds = computed( - () => new Set(this.playlistItems().map((playlist) => playlist._id)) - ); - - constructor() { - effect(() => { - const playlistId = this.playlistId(); - this.collectionContext.setCategoryId('all'); - void this.downloadsService.loadDownloads(playlistId || undefined); - }); - } - - getProgress(item: DownloadItem): number { - return this.downloadsService.getProgressPercent(item); - } - - formatBytes(bytes: number | undefined): string { - if (!bytes) return '0 B'; - return this.downloadsService.formatBytes(bytes); - } - - getStatusIcon(status: string): string { - switch (status) { - case 'queued': - return 'schedule'; - case 'downloading': - return 'downloading'; - case 'paused': - return 'pause_circle'; - case 'completed': - return 'check_circle'; - case 'failed': - return 'error'; - case 'canceled': - return 'cancel'; - default: - return 'help'; - } - } - - getStatusColor(status: string): string { - switch (status) { - case 'queued': - return 'status-queued'; - case 'downloading': - return 'status-downloading'; - case 'paused': - return 'status-paused'; - case 'completed': - return 'status-completed'; - case 'failed': - case 'canceled': - return 'status-failed'; - default: - return ''; - } - } - - async copyUrl(item: DownloadItem): Promise { - try { - await navigator.clipboard.writeText(item.url); - this.snackBar.open( - this.translate.instant('DOWNLOADS.URL_COPIED'), - undefined, - { duration: 2000, horizontalPosition: 'start' } - ); - } catch { - this.snackBar.open( - this.translate.instant('DOWNLOADS.URL_COPY_FAILED'), - undefined, - { duration: 3000, horizontalPosition: 'start' } - ); - } - } - - async cancel(item: DownloadItem) { - const result = await this.downloadsService.cancelDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async pause(item: DownloadItem) { - const result = await this.downloadsService.pauseDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async resume(item: DownloadItem) { - const result = await this.downloadsService.resumeDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async retry(item: DownloadItem) { - const result = await this.downloadsService.retryDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async remove(item: DownloadItem) { - const result = await this.downloadsService.removeDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async play(item: DownloadItem) { - if (item.filePath) { - const result = await this.downloadsService.playDownload( - item.filePath - ); - if (!result.success) { - this.handleFileActionError(result.error); - } - } - } - - async reveal(item: DownloadItem) { - if (item.filePath) { - const result = await this.downloadsService.revealFile( - item.filePath - ); - if (!result.success) { - this.handleFileActionError(result.error); - } - } - } - - async changeFolder() { + async changeFolder(): Promise { await this.downloadsService.selectFolder(); } - async clearCompleted() { - this.dialogService.openConfirmDialog({ - title: this.translate.instant( - 'DOWNLOADS.CLEAR_COMPLETED_DIALOG.TITLE' - ), - message: this.translate.instant( - 'DOWNLOADS.CLEAR_COMPLETED_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_COMPLETED'), - onConfirm: async (): Promise => { - await this.downloadsService.clearCompleted(this.playlistId()); - }, - }); + formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + return this.downloadsService.formatBytes(bytes); } - formatEpisodeLabel(item: DownloadItem): string { - if (item.contentType !== 'episode') return ''; - const s = item.seasonNumber?.toString().padStart(2, '0') || '00'; - const e = item.episodeNumber?.toString().padStart(2, '0') || '00'; - return `S${s}E${e}`; + async runAction(action: DownloadItemAction): Promise { + return this.actions.run(action); } - hasPoster(item: DownloadItem): boolean { - return ( - !!item.posterUrl && - !this.failedPosterKeys()[this.getPosterKey(item)] + clearFinished(): void { + this.actions.clearFinished(this.playlistId() || undefined); + } + + openDownloadedSeries(group: DownloadSeriesCardViewModel): void { + const liveMembers = computed( + () => + this.model().library.find( + (entity): entity is DownloadSeriesCardViewModel => + entity.kind === 'series' && entity.key === group.key + )?.members ?? [] ); - } - - markPosterFailed(item: DownloadItem): void { - const key = this.getPosterKey(item); - this.failedPosterKeys.update((state) => { - if (state[key]) { - return state; - } - - return { - ...state, - [key]: true, - }; + const ref = this.dialog.open(DownloadedSeriesDialogComponent, { + data: group, + width: 'min(720px, calc(100vw - 32px))', + maxWidth: 'min(720px, calc(100vw - 32px))', + maxHeight: 'min(760px, calc(100vh - 32px))', }); - } - - getPosterPlaceholderIcon(item: DownloadItem): string { - return item.contentType === 'episode' ? 'video_library' : 'movie'; - } - - hasSourcePlaylist(item: DownloadItem): boolean { - return this.availablePlaylistIds().has(item.playlistId); - } - - isItemNavigable(item: DownloadItem): boolean { - return ( - !!item.playlistId && - this.hasSourcePlaylist(item) && - this.getTargetContentId(item) !== null + ref.componentRef?.setInput('members', liveMembers); + ref.componentRef?.setInput('pendingIds', this.pendingIds); + const actionSubscription = ref.componentInstance.itemAction.subscribe( + (action) => void this.runAction(action) ); + const cleanup = () => actionSubscription.unsubscribe(); + ref.afterClosed() + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe(cleanup); + this.destroyRef.onDestroy(cleanup); } - onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void { - if (event.target !== event.currentTarget) { + openOfflineDetail(item: DownloadItem): void { + if (this.pendingIds().has(item.id)) { return; } - - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - void this.openInLibrary(item); + void this.router.navigate([String(item.id)], { + relativeTo: this.route, + state: { returnUrl: this.router.url }, + }); } async openInLibrary(item: DownloadItem): Promise { - if (!this.hasSourcePlaylist(item)) { - this.snackBar.open( - this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'), - undefined, - { duration: 3000, horizontalPosition: 'start' } - ); + if (this.pendingIds().has(item.id)) { return; } - - if (!this.isItemNavigable(item)) { + if (!this.availablePlaylistIds().has(item.playlistId)) { + this.actions.showMessage('DOWNLOADS.SOURCE_PLAYLIST_MISSING', 3000); return; } - - const source = await this.resolveSourceType(item.playlistId); - if (!source) { + if (!this.navigation.canOpen(item, this.availablePlaylistIds())) { return; } - - if (source === 'xtream') { - await this.openXtreamItem(item); - return; - } - - await this.openStalkerItem(item); - } - - private getTargetContentId(item: DownloadItem): number | null { - const id = - item.contentType === 'episode' - ? (item.seriesXtreamId ?? item.xtreamId) - : item.xtreamId; - const numeric = Number(id); - return Number.isFinite(numeric) ? numeric : null; - } - - private buildPlaylistRoute( - source: PortalSource, - playlistId: string, - segments: Array - ): Array { - const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams'; - return ['/workspace', sourceSegment, playlistId, ...segments]; - } - - private async resolveSourceType( - playlistId: string - ): Promise { - try { - const playlist = await firstValueFrom( - this.playlistsService.getPlaylistById(playlistId) - ); - - if (!playlist) { - return null; - } - - if (playlist.portalUrl && playlist.macAddress) { - return 'stalker'; - } - - return 'xtream'; - } catch { - return null; + if (!(await this.navigation.open(item))) { + this.actions.showActionError(); } } - private async openXtreamItem(item: DownloadItem): Promise { - const targetId = this.getTargetContentId(item); - if (targetId === null) return; - - const contentType = item.contentType === 'episode' ? 'series' : 'vod'; - const content = await this.dbService.getContentByXtreamId( - targetId, - item.playlistId - ); - const categoryId = content?.category_id; - - if (categoryId === null || categoryId === undefined) { - await this.router.navigate( - this.buildPlaylistRoute('xtream', item.playlistId, [ - contentType, - ]) - ); - return; - } - - await this.router.navigate( - this.buildPlaylistRoute('xtream', item.playlistId, [ - contentType, - String(categoryId), - String(targetId), - ]) - ); - } - - private normalizePortalItemId(value: unknown): string { - const raw = String(value ?? '').trim(); - if (!raw) return ''; - return raw.includes(':') ? raw.split(':')[0] : raw; - } - - private normalizeStalkerCategoryId( - value: unknown, - fallback: 'vod' | 'series' - ): 'vod' | 'series' | 'itv' { - const normalized = String(value ?? '').toLowerCase(); - if (normalized === 'movie') return 'vod'; - if ( - normalized === 'vod' || - normalized === 'series' || - normalized === 'itv' - ) { - return normalized; - } - return fallback; - } - - private findMatchingStalkerRecentItem( - items: Array>, - targetId: number - ): Record | undefined { - const expectedId = String(targetId); - return items.find((recentItem) => { - const candidates = [ - recentItem['id'], - recentItem['movie_id'], - recentItem['series_id'], - recentItem['stream_id'], - ]; - - return candidates.some( - (candidate) => - this.normalizePortalItemId(candidate) === expectedId - ); + private buildCategories(): XtreamCategory[] { + this.languageChange(); + const counts = this.model().counts; + const categories = buildStandardCollectionCategories({ + labels: { + all: this.translate.instant(FILTER_KEYS.all), + movie: this.translate.instant(FILTER_KEYS.movie), + live: '', + series: this.translate.instant(FILTER_KEYS.series), + }, + counts, }); - } - - private async buildStalkerOpenState( - item: DownloadItem, - targetId: number, - fallbackCategory: 'vod' | 'series' - ): Promise> { - try { - const items = (await firstValueFrom( - this.playlistsService.getPortalRecentlyViewed(item.playlistId) - )) as Array>; - - const matched = this.findMatchingStalkerRecentItem(items, targetId); - - if (matched) { - return { - ...matched, - id: - matched['id'] ?? - matched['series_id'] ?? - matched['movie_id'] ?? - String(targetId), - category_id: this.normalizeStalkerCategoryId( - matched['category_id'], - fallbackCategory - ), - title: matched['title'] ?? item.title, - name: matched['name'] ?? matched['o_name'] ?? item.title, - }; - } - } catch { - // Ignore and use fallback state below. - } - - return { - id: String(targetId), - category_id: fallbackCategory, - title: item.title, - name: item.title, - o_name: item.title, - cover: item.posterUrl, - logo: item.posterUrl, - }; - } - - private async openStalkerItem(item: DownloadItem): Promise { - const targetId = this.getTargetContentId(item); - if (targetId === null) return; - - const fallbackCategory = - item.contentType === 'episode' ? 'series' : 'vod'; - const openRecentItem = await this.buildStalkerOpenState( - item, - targetId, - fallbackCategory - ); - - await this.router.navigate( - this.buildPlaylistRoute('stalker', item.playlistId, ['recent']), - { - state: { openRecentItem }, - } - ); - } - - private getPosterKey(item: DownloadItem): string { - return `${item.id}:${item.posterUrl ?? ''}`; - } - - private showActionError(error?: string): void { - const message = error - ? `${this.translate.instant('DOWNLOADS.ACTION_FAILED')}: ${error}` - : this.translate.instant('DOWNLOADS.ACTION_FAILED'); - - this.snackBar.open(message, undefined, { - duration: 4000, - horizontalPosition: 'start', - }); - } - - private handleFileActionError(error?: string): void { - const message = - error === 'File not found' - ? this.translate.instant('DOWNLOADS.FILE_NOT_FOUND') - : this.translate.instant('DOWNLOADS.FILE_ACTION_ERROR'); - - this.snackBar.open(message, undefined, { - duration: 3000, - horizontalPosition: 'start', + categories.push({ + id: 4, + category_id: 'in-progress', + category_name: this.translate.instant(FILTER_KEYS.inProgress), + count: counts.inProgress, + parent_id: 0, }); + return categories; } } diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.spec.ts new file mode 100644 index 000000000..c914c1b82 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.spec.ts @@ -0,0 +1,60 @@ +import { TMDB_DETAILS_CACHE_TTL_MS } from '@iptvnator/services'; +import { + COMPLETED_METADATA_REFRESH_MAX_ENTRIES, + CompletedMetadataRefreshThrottle, +} from './download-metadata-refresh'; + +describe('CompletedMetadataRefreshThrottle', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-31T08:30:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('expires completed attempts with the TMDB details TTL', () => { + const throttle = new CompletedMetadataRefreshThrottle(); + throttle.mark('download:17', 'en', 1); + + expect(throttle.isCompleted('download:17', 'en')).toBe(true); + + jest.advanceTimersByTime(TMDB_DETAILS_CACHE_TTL_MS + 1); + + expect(throttle.isCompleted('download:17', 'en')).toBe(false); + }); + + it('bounds completed attempts by evicting the oldest group and language', () => { + const throttle = new CompletedMetadataRefreshThrottle(); + for ( + let index = 0; + index <= COMPLETED_METADATA_REFRESH_MAX_ENTRIES; + index += 1 + ) { + throttle.mark(`download:${index}`, 'en', index + 1); + } + + expect(throttle.isCompleted('download:0', 'en')).toBe(false); + expect( + throttle.isCompleted( + `download:${COMPLETED_METADATA_REFRESH_MAX_ENTRIES}`, + 'en' + ) + ).toBe(true); + }); + + it('does not let older cleanup erase a newer completed generation', () => { + const throttle = new CompletedMetadataRefreshThrottle(); + throttle.mark('series:playlist-a:93', 'en', 1); + throttle.mark('series:playlist-a:93', 'en', 2); + + throttle.clear('series:playlist-a:93', 'en', 1); + + expect(throttle.isCompleted('series:playlist-a:93', 'en')).toBe(true); + + throttle.clear('series:playlist-a:93', 'en', 2); + + expect(throttle.isCompleted('series:playlist-a:93', 'en')).toBe(false); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts new file mode 100644 index 000000000..a0224b62a --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-refresh.ts @@ -0,0 +1,262 @@ +import { TMDB_DETAILS_CACHE_TTL_MS } from '@iptvnator/services'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; + +export const TMDB_REFRESH_STATE = { + DISABLED: 'disabled', + FAILED: 'failed', + SUCCEEDED: 'succeeded', +} as const; + +export type TmdbRefreshState = + (typeof TMDB_REFRESH_STATE)[keyof typeof TMDB_REFRESH_STATE]; + +export interface TmdbRefreshResult { + snapshot: DownloadMetadataSnapshot; + state: TmdbRefreshState; +} + +interface FinalizeMetadataRefreshInput { + language: string; + local: DownloadMetadataSnapshot; + providerSucceeded: boolean; + snapshot: DownloadMetadataSnapshot; + tmdbState: TmdbRefreshState; +} + +export interface FinalizedMetadataRefresh { + completedAttempt: boolean; + shouldPersist: boolean; + snapshot: DownloadMetadataSnapshot; +} + +export const COMPLETED_METADATA_REFRESH_MAX_ENTRIES = 256; + +interface CompletedMetadataRefresh { + expiresAt: number; + generation: number; +} + +interface PersistMetadataRefreshInput { + completedAttempt: boolean; + generation: number; + groupKey: string; + language: string; + persist(): Promise<{ success: boolean }>; +} + +interface MetadataLanguageSettings { + readonly language: () => string; +} + +function isSparse(snapshot: DownloadMetadataSnapshot): boolean { + return !snapshot.plot || (!snapshot.posterUrl && !snapshot.backdropUrl); +} + +function isFresh(snapshot: DownloadMetadataSnapshot): boolean { + const enrichedAt = Date.parse(snapshot.enrichedAt ?? ''); + const age = Date.now() - enrichedAt; + return ( + Number.isFinite(enrichedAt) && + age >= 0 && + age <= TMDB_DETAILS_CACHE_TTL_MS + ); +} + +export function currentMetadataLanguage( + settings: MetadataLanguageSettings +): string { + try { + return settings.language().trim() || 'en'; + } catch { + return 'en'; + } +} + +export function needsMetadataRefresh( + snapshot: DownloadMetadataSnapshot | undefined, + language: string, + completedSparseAttempt = false +): boolean { + if (!snapshot || snapshot.language !== language) { + return true; + } + if (isSparse(snapshot)) { + return !completedSparseAttempt; + } + return !isFresh(snapshot); +} + +export function withoutMetadataFreshness( + snapshot: DownloadMetadataSnapshot +): DownloadMetadataSnapshot { + const pending = { ...snapshot }; + delete pending.enrichedAt; + return pending; +} + +function sortedValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortedValue); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.keys(value) + .sort() + .reduce>((result, key) => { + result[key] = sortedValue((value as Record)[key]); + return result; + }, {}); +} + +function materiallyEqual( + left: DownloadMetadataSnapshot, + right: DownloadMetadataSnapshot +): boolean { + const leftFields = { ...left }; + const rightFields = { ...right }; + delete leftFields.enrichedAt; + delete rightFields.enrichedAt; + return ( + JSON.stringify(sortedValue(leftFields)) === + JSON.stringify(sortedValue(rightFields)) + ); +} + +function preserveAttemptIdentity( + snapshot: DownloadMetadataSnapshot, + local: DownloadMetadataSnapshot +): DownloadMetadataSnapshot { + const preserved = { ...snapshot, language: local.language }; + return local.enrichedAt === undefined + ? withoutMetadataFreshness(preserved) + : { ...preserved, enrichedAt: local.enrichedAt }; +} + +export function finalizeMetadataRefresh({ + language, + local, + providerSucceeded, + snapshot, + tmdbState, +}: FinalizeMetadataRefreshInput): FinalizedMetadataRefresh { + const refreshSucceeded = + tmdbState === TMDB_REFRESH_STATE.SUCCEEDED || + (tmdbState === TMDB_REFRESH_STATE.DISABLED && providerSucceeded); + const finalized = refreshSucceeded + ? { ...snapshot, language, enrichedAt: new Date().toISOString() } + : preserveAttemptIdentity(snapshot, local); + const shouldPersist = + refreshSucceeded || !materiallyEqual(local, finalized); + return { + completedAttempt: refreshSucceeded, + shouldPersist, + snapshot: shouldPersist ? finalized : local, + }; +} + +function completedRefreshKey(groupKey: string, language: string): string { + return `${groupKey}\u0000${language}`; +} + +export class CompletedMetadataRefreshThrottle { + private readonly attempts = new Map(); + + isCompleted(groupKey: string, language: string): boolean { + const now = Date.now(); + this.deleteExpired(now); + return this.attempts.has(completedRefreshKey(groupKey, language)); + } + + mark(groupKey: string, language: string, generation: number): void { + const now = Date.now(); + const key = completedRefreshKey(groupKey, language); + this.deleteExpired(now); + this.attempts.delete(key); + while (this.attempts.size >= COMPLETED_METADATA_REFRESH_MAX_ENTRIES) { + const oldestKey = this.attempts.keys().next().value; + if (oldestKey === undefined) break; + this.attempts.delete(oldestKey); + } + this.attempts.set(key, { + expiresAt: now + TMDB_DETAILS_CACHE_TTL_MS, + generation, + }); + } + + clear(groupKey: string, language: string, generation: number): void { + const key = completedRefreshKey(groupKey, language); + if (this.attempts.get(key)?.generation === generation) { + this.attempts.delete(key); + } + } + + private deleteExpired(now: number): void { + for (const [key, attempt] of this.attempts) { + if (attempt.expiresAt < now) { + this.attempts.delete(key); + } + } + } +} + +export class LatestMetadataWriteGuard { + private generation = 0; + private readonly latestByKey = new Map(); + + begin(key: string): number { + const generation = ++this.generation; + this.latestByKey.set(key, generation); + return generation; + } + + isLatest(key: string, generation: number): boolean { + return this.latestByKey.get(key) === generation; + } + + finish(key: string, generation: number): void { + if (this.isLatest(key, generation)) { + this.latestByKey.delete(key); + } + } +} + +export class MetadataRefreshCoordinator { + private readonly completed = new CompletedMetadataRefreshThrottle(); + private readonly writes = new LatestMetadataWriteGuard(); + + begin(groupKey: string): number { + return this.writes.begin(groupKey); + } + + finish(groupKey: string, generation: number): void { + this.writes.finish(groupKey, generation); + } + + isCompleted(groupKey: string, language: string): boolean { + return this.completed.isCompleted(groupKey, language); + } + + async persistLatest({ + completedAttempt, + generation, + groupKey, + language, + persist, + }: PersistMetadataRefreshInput): Promise { + if (!this.writes.isLatest(groupKey, generation)) return; + if (completedAttempt) { + this.completed.mark(groupKey, language, generation); + } + try { + const result = await persist(); + if (!result.success && completedAttempt) { + this.completed.clear(groupKey, language, generation); + } + } catch { + if (completedAttempt) { + this.completed.clear(groupKey, language, generation); + } + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts new file mode 100644 index 000000000..f526a444e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata-tmdb-seeds.ts @@ -0,0 +1,121 @@ +import type { + DownloadMetadataSnapshot, + StalkerVodInfo, + TmdbEnrichedCastMember, + XtreamSerieInfo, + XtreamVodInfo, +} from '@iptvnator/shared/interfaces'; + +const MAX_PEOPLE = 30; + +interface SeedPeopleFields { + tmdb_cast?: TmdbEnrichedCastMember[]; + tmdb_directors?: TmdbEnrichedCastMember[]; +} + +function seedPeople( + people: DownloadMetadataSnapshot['cast'] +): TmdbEnrichedCastMember[] | undefined { + const seeded = people?.slice(0, MAX_PEOPLE).map((person) => ({ + name: person.name, + ...(person.role === undefined ? {} : { character: person.role }), + profileUrl: person.profileUrl ?? null, + ...(person.tmdbPersonId === undefined + ? {} + : { tmdbPersonId: person.tmdbPersonId }), + })); + return seeded && seeded.length > 0 ? seeded : undefined; +} + +function seedPeopleFields( + snapshot: DownloadMetadataSnapshot +): SeedPeopleFields { + const cast = seedPeople(snapshot.cast); + const directors = seedPeople(snapshot.creators); + return { + ...(cast === undefined ? {} : { tmdb_cast: cast }), + ...(directors === undefined ? {} : { tmdb_directors: directors }), + }; +} + +export function movieSeed(snapshot: DownloadMetadataSnapshot): XtreamVodInfo { + const cast = snapshot.cast?.map(({ name }) => name).join(', ') ?? ''; + return { + kinopoisk_url: '', + tmdb_id: snapshot.tmdbId ?? 0, + name: snapshot.title, + o_name: snapshot.originalTitle ?? '', + cover_big: snapshot.posterUrl ?? '', + movie_image: snapshot.posterUrl ?? '', + releasedate: snapshot.releaseDate ?? '', + episode_run_time: snapshot.durationMinutes ?? 0, + youtube_trailer: '', + director: snapshot.creators?.map(({ name }) => name).join(', ') ?? '', + actors: cast, + cast, + description: snapshot.plot ?? '', + plot: snapshot.plot ?? '', + age: '', + mpaa_rating: '', + rating_count_kinopoisk: 0, + country: '', + genre: snapshot.genres?.join(', ') ?? '', + backdrop_path: snapshot.backdropUrl ? [snapshot.backdropUrl] : [], + duration_secs: (snapshot.durationMinutes ?? 0) * 60, + duration: '', + video: [], + audio: [], + bitrate: 0, + rating: snapshot.rating ?? 0, + ...seedPeopleFields(snapshot), + }; +} + +export function seriesSeed( + snapshot: DownloadMetadataSnapshot +): XtreamSerieInfo { + return { + name: snapshot.title, + cover: snapshot.posterUrl ?? '', + plot: snapshot.plot ?? '', + cast: snapshot.cast?.map(({ name }) => name).join(', ') ?? '', + director: snapshot.creators?.map(({ name }) => name).join(', ') ?? '', + genre: snapshot.genres?.join(', ') ?? '', + releaseDate: snapshot.releaseDate ?? '', + last_modified: '', + rating: snapshot.rating === undefined ? '' : String(snapshot.rating), + rating_5based: 0, + backdrop_path: snapshot.backdropUrl ? [snapshot.backdropUrl] : [], + youtube_trailer: '', + episode_run_time: + snapshot.durationMinutes === undefined + ? '' + : String(snapshot.durationMinutes), + category_id: snapshot.providerCategoryId ?? '', + tmdb_id: snapshot.tmdbId, + ...seedPeopleFields(snapshot), + }; +} + +export function stalkerSeed(snapshot: DownloadMetadataSnapshot): { + info: StalkerVodInfo; +} { + return { + info: { + movie_image: snapshot.posterUrl ?? '', + description: snapshot.plot ?? '', + name: snapshot.title, + o_name: snapshot.originalTitle, + actors: snapshot.cast?.map(({ name }) => name).join(', ') ?? '', + director: + snapshot.creators?.map(({ name }) => name).join(', ') ?? '', + releasedate: snapshot.releaseDate ?? '', + genre: snapshot.genres?.join(', ') ?? '', + rating_imdb: + snapshot.rating === undefined ? '' : String(snapshot.rating), + rating_kinopoisk: '', + tmdb_id: snapshot.tmdbId, + ...seedPeopleFields(snapshot), + }, + }; +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts new file mode 100644 index 000000000..1a25f2669 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.spec.ts @@ -0,0 +1,389 @@ +import type { TmdbMovieDetails, TmdbTvDetails } from '@iptvnator/services'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { + mapProviderToDownloadSnapshot, + mergeSnapshotWithTmdb, +} from './download-metadata.mapper'; + +const providerSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'de', + mediaKind: 'movie', + title: 'Provider title', + originalTitle: 'Provider original', + plot: 'Provider plot', + posterUrl: 'https://images.example.test/provider-poster.jpg', + providerCategoryId: '12', + cast: [{ name: 'Provider actor', role: 'Provider role' }], + enrichedAt: '2026-07-31T08:30:00.000Z', +}; + +const tmdbMovie: TmdbMovieDetails = { + id: 603, + title: 'TMDB translated title', + original_title: 'TMDB original title', + overview: 'Localized TMDB overview', + release_date: '1999-03-31', + runtime: 136, + genres: [ + { id: 1, name: 'Science Fiction' }, + { id: 2, name: 'Action' }, + ], + vote_average: 8.2, + vote_count: 100, + poster_path: '/tmdb-poster.jpg', + backdrop_path: '/tmdb-backdrop.jpg', + credits: { + cast: [ + { + id: 1, + name: 'Actor One', + character: 'Lead', + order: 0, + profile_path: '/one.jpg', + }, + { + id: 2, + name: 'Actor Two', + character: 'Guide', + order: 1, + profile_path: '/two.jpg', + }, + ], + crew: [ + { + id: 3, + name: 'Director One', + job: 'Director', + profile_path: '/director.jpg', + }, + ], + }, +}; + +describe('download metadata mapper', () => { + it('keeps provider identity while TMDB replaces editorial fields', () => { + const merged = mergeSnapshotWithTmdb( + providerSnapshot, + tmdbMovie, + 'xtream' + ); + + expect(merged.title).toBe('Provider title'); + expect(merged.originalTitle).toBe('Provider original'); + expect(merged.providerCategoryId).toBe('12'); + expect(merged.plot).toBe('Localized TMDB overview'); + expect(merged.posterUrl).toBe( + 'https://image.tmdb.org/t/p/w500/tmdb-poster.jpg' + ); + expect(merged.backdropUrl).toBe( + 'https://image.tmdb.org/t/p/w1280/tmdb-backdrop.jpg' + ); + expect(merged.cast).toHaveLength(2); + expect(merged.cast?.[0]).toEqual( + expect.objectContaining({ name: 'Actor One', role: 'Lead' }) + ); + expect(merged.creators?.[0]?.name).toBe('Director One'); + expect(merged.tmdbId).toBe(603); + }); + + it('keeps a normalized Stalker provider title through TMDB enrichment', () => { + const merged = mergeSnapshotWithTmdb( + providerSnapshot, + tmdbMovie, + 'stalker' + ); + + expect(merged.title).toBe('Provider title'); + expect(merged.originalTitle).toBe('Provider original'); + }); + + it.each([ + { + label: 'Xtream movie', + source: 'xtream' as const, + mediaKind: 'movie' as const, + details: { id: 603 } satisfies TmdbMovieDetails, + }, + { + label: 'Xtream series', + source: 'xtream' as const, + mediaKind: 'series' as const, + details: { id: 900 } satisfies TmdbTvDetails, + }, + { + label: 'Stalker movie', + source: 'stalker' as const, + mediaKind: 'movie' as const, + details: { id: 603 } satisfies TmdbMovieDetails, + }, + ])('preserves rich people for sparse $label credits', (scenario) => { + const richActor = { + tmdbPersonId: 7, + name: 'Provider actor', + role: 'Lead', + profileUrl: 'https://image.tmdb.org/t/p/w185/provider-actor.jpg', + }; + const richCreator = { + tmdbPersonId: 11, + name: 'Provider creator', + role: 'Director', + profileUrl: 'https://image.tmdb.org/t/p/w185/provider-creator.jpg', + }; + const merged = mergeSnapshotWithTmdb( + { + ...providerSnapshot, + mediaKind: scenario.mediaKind, + cast: [ + richActor, + ...Array.from({ length: 30 }, (_, index) => ({ + name: `Provider actor ${index + 2}`, + })), + ], + creators: [richCreator], + }, + scenario.details, + scenario.source + ); + + expect(merged.cast).toHaveLength(30); + expect(merged.cast?.[0]).toEqual(richActor); + expect(merged.creators?.[0]).toEqual(richCreator); + }); + + it('maps a Stalker recent payload without copying credentials or playback fields', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + movie_id: '41', + title: 'Stalker provider title', + category_id: 'vod', + cover: 'https://images.example.test/stalker.jpg', + description: 'Stalker provider plot', + actors: 'First Actor, Second Actor', + genre: 'Drama, Mystery', + cmd: 'ffmpeg http://stream.example.test/movie/41', + portalUrl: 'https://portal.example.test', + username: 'account-user', + password: 'account-secret', + token: 'session-token', + }, + }); + + expect(mapped.title).toBe('Stalker provider title'); + expect(mapped.plot).toBe('Stalker provider plot'); + expect(mapped.posterUrl).toBe( + 'https://images.example.test/stalker.jpg' + ); + expect(mapped.genres).toEqual(['Drama', 'Mystery']); + expect(mapped.cast?.map(({ name }) => name)).toEqual([ + 'First Actor', + 'Second Actor', + ]); + const encoded = JSON.stringify(mapped); + expect(encoded).not.toContain('account-secret'); + expect(encoded).not.toContain('session-token'); + expect(encoded).not.toContain('stream.example.test'); + expect(mapped).not.toHaveProperty('cmd'); + }); + + it('prefers a flat Stalker recent title over its generic name', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + title: 'Canonical recent title', + name: 'video_name_format', + }, + }); + + expect(mapped.title).toBe('Canonical recent title'); + }); + + it('prefers a flat Stalker recent original title over its generic name', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + o_name: 'Canonical recent original title', + name: 'video_name_format', + }, + }); + + expect(mapped.title).toBe('Canonical recent original title'); + expect(mapped.originalTitle).toBe('Canonical recent original title'); + }); + + it('normalizes nested Stalker series identity and legacy editorial fallbacks', () => { + const fallback: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Downloaded episode title', + episode: { + title: 'Local episode', + seasonNumber: 2, + episodeNumber: 4, + }, + }; + + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'series', + fallback, + provider: { + title: 'Root episode title', + screenshot_uri: + 'https://images.example.test/stills/legacy-series.jpg', + genres_str: 'Drama, Mystery', + year: '2024', + info: { + name: 'Nested parent series title', + o_name: 'Original parent title', + }, + }, + }); + + expect(mapped.title).toBe('Nested parent series title'); + expect(mapped.originalTitle).toBe('Original parent title'); + expect(mapped.posterUrl).toBe( + 'https://images.example.test/stills/legacy-series.jpg' + ); + expect(mapped.genres).toEqual(['Drama', 'Mystery']); + expect(mapped.releaseDate).toBe('2024'); + expect(mapped.year).toBe(2024); + expect(mapped.episode).toEqual(fallback.episode); + }); + + it('falls back from blank nested Stalker artwork to the root cover', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + cover: 'https://images.example.test/covers/root-cover.jpg', + info: { + name: 'Nested title', + movie_image: '', + }, + }, + }); + + expect(mapped.posterUrl).toBe( + 'https://images.example.test/covers/root-cover.jpg' + ); + }); + + it('falls back from blank nested Stalker artwork to root poster_url', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'stalker', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + poster_url: + 'https://images.example.test/posters/canonical-recent.jpg', + info: { + name: 'Nested title', + movie_image: '', + }, + }, + }); + + expect(mapped.posterUrl).toBe( + 'https://images.example.test/posters/canonical-recent.jpg' + ); + }); + + it('bounds mapped people and genres to the persisted DTO limits', () => { + const mapped = mapProviderToDownloadSnapshot({ + source: 'xtream', + language: 'en', + mediaKind: 'movie', + fallback: providerSnapshot, + provider: { + title: 'Large provider record', + category_id: 5, + genre: Array.from( + { length: 25 }, + (_, index) => `Genre ${index + 1}` + ).join(', '), + actors: Array.from( + { length: 35 }, + (_, index) => `Actor ${index + 1}` + ).join(', '), + director: Array.from( + { length: 35 }, + (_, index) => `Creator ${index + 1}` + ).join(', '), + }, + }); + + expect(mapped.genres).toHaveLength(20); + expect(mapped.cast).toHaveLength(30); + expect(mapped.creators).toHaveLength(30); + }); + + it('preserves the downloaded episode and never derives episodes from TMDB series data', () => { + const snapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Provider series', + providerCategoryId: '9', + episode: { + title: 'Downloaded episode', + plot: 'Local episode plot', + stillUrl: 'https://images.example.test/local-still.jpg', + seasonNumber: 2, + episodeNumber: 7, + }, + enrichedAt: '2026-07-31T08:30:00.000Z', + }; + const details = { + id: 900, + name: 'TMDB series title', + overview: 'Localized series overview', + poster_path: '/series.jpg', + seasons: [{ season_number: 1, episode_count: 12 }], + episodes: [{ season_number: 1, episode_number: 1 }], + } as TmdbTvDetails & Record; + + const merged = mergeSnapshotWithTmdb(snapshot, details, 'stalker'); + + expect(merged.title).toBe('Provider series'); + expect(merged.episode).toEqual(snapshot.episode); + expect(merged).not.toHaveProperty('episodes'); + expect(merged).not.toHaveProperty('seasons'); + }); + + it('does not fabricate episode metadata for a legacy series snapshot without it', () => { + const snapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Legacy provider series', + }; + + const merged = mergeSnapshotWithTmdb( + snapshot, + { + id: 901, + name: 'TMDB series title', + overview: 'Localized series overview', + }, + 'xtream' + ); + + expect(merged).not.toHaveProperty('episode'); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts new file mode 100644 index 000000000..6124b40cd --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-metadata.mapper.ts @@ -0,0 +1,279 @@ +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, +} from '@iptvnator/portal/shared/util'; +import { + mergeSerieInfoWithTmdb, + mergeStalkerInfoWithTmdb, + mergeVodInfoWithTmdb, + type TmdbMovieDetails, + type TmdbTvDetails, +} from '@iptvnator/services'; +import type { + DownloadMetadataPerson, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; +import { + movieSeed, + seriesSeed, + stalkerSeed, +} from './download-metadata-tmdb-seeds'; + +export type DownloadMetadataProviderSource = 'xtream' | 'stalker'; + +export interface MapProviderToDownloadSnapshotInput { + source: DownloadMetadataProviderSource; + language: string; + mediaKind: DownloadMetadataSnapshot['mediaKind']; + fallback: DownloadMetadataSnapshot; + provider: unknown; +} + +type ProviderRecord = Record; + +function record(value: unknown): ProviderRecord | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as ProviderRecord) + : undefined; +} + +function first( + records: readonly (ProviderRecord | undefined)[], + keys: readonly string[] +): unknown { + for (const source of records) { + for (const key of keys) { + const value = source?.[key]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + } + return undefined; +} + +function string(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') { + return undefined; + } + const normalized = String(value).trim(); + return normalized ? normalized : undefined; +} + +function number(value: unknown): number | undefined { + const parsed = typeof value === 'number' ? value : Number(string(value)); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function integer(value: unknown): number | undefined { + const parsed = number(value); + return parsed !== undefined && Number.isSafeInteger(parsed) + ? parsed + : undefined; +} + +function strings(value: unknown): string[] | undefined { + const values = Array.isArray(value) ? value : string(value)?.split(','); + const normalized = values + ?.map((entry) => string(entry)) + .filter((entry): entry is string => entry !== undefined); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function people(value: unknown): DownloadMetadataPerson[] | undefined { + if (!Array.isArray(value)) { + return strings(value)?.map((name) => ({ name })); + } + const normalized = value + .map((entry): DownloadMetadataPerson | null => { + const source = record(entry); + const name = string(source?.['name']); + if (!name) { + return null; + } + const tmdbPersonId = integer(source?.['tmdbPersonId']); + const role = string(source?.['character'] ?? source?.['role']); + const profileUrl = string(source?.['profileUrl']); + return { + ...(tmdbPersonId === undefined ? {} : { tmdbPersonId }), + name, + ...(role === undefined ? {} : { role }), + ...(profileUrl === undefined ? {} : { profileUrl }), + }; + }) + .filter((entry): entry is DownloadMetadataPerson => entry !== null); + return normalized.length > 0 ? normalized : undefined; +} + +function backdrop(value: unknown): string | undefined { + return string(Array.isArray(value) ? value[0] : value); +} + +function releaseYear(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function durationMinutes( + records: readonly ProviderRecord[] +): number | undefined { + const seconds = number(first(records, ['duration_secs'])); + if (seconds !== undefined && seconds > 0) { + return Math.round(seconds / 60); + } + const minutes = number( + first(records, ['durationMinutes', 'episode_run_time', 'runtime']) + ); + return minutes !== undefined && minutes > 0 + ? Math.round(minutes) + : undefined; +} + +export function mapProviderToDownloadSnapshot({ + source, + language, + mediaKind, + fallback, + provider, +}: MapProviderToDownloadSnapshotInput): DownloadMetadataSnapshot { + const root = record(provider) ?? {}; + const info = record(root['info']); + const editorial = [info, root]; + const identity = [root, info]; + const title = + (source === 'stalker' + ? info + ? (string(info['name']) ?? + string(info['o_name']) ?? + string(root['o_name']) ?? + string(root['name']) ?? + string(root['title'])) + : (string(root['title']) ?? + string(root['o_name']) ?? + string(root['name'])) + : string(first(identity, ['title', 'name']))) ?? fallback.title; + const originalTitle = + string(first(editorial, ['o_name', 'originalTitle'])) ?? + fallback.originalTitle; + const plot = + string(first(editorial, ['plot', 'description'])) ?? fallback.plot; + const releaseDate = + string( + first( + editorial, + source === 'stalker' + ? ['releaseDate', 'releasedate', 'year'] + : ['releaseDate', 'releasedate'] + ) + ) ?? fallback.releaseDate; + const providerGenres = strings( + first( + editorial, + source === 'stalker' + ? ['genre', 'genres', 'genres_str'] + : ['genre', 'genres'] + ) + ); + const providerCast = people( + first(editorial, ['tmdb_cast', 'actors', 'cast']) + ); + const providerCreators = people( + first(editorial, ['tmdb_directors', 'director', 'creators']) + ); + const posterUrl = + (source === 'stalker' + ? (string(info?.['movie_image']) ?? + string(root['cover']) ?? + string(root['screenshot_uri']) ?? + string(root['poster_url']) ?? + string(root['logo'])) + : string( + first(editorial, [ + 'movie_image', + 'cover_big', + 'cover', + 'poster_url', + 'posterUrl', + 'logo', + ]) + )) ?? fallback.posterUrl; + const backdropUrl = + backdrop( + first(editorial, ['tmdb_backdrop', 'backdrop_path', 'backdrop_url']) + ) ?? fallback.backdropUrl; + const rating = + number(first(editorial, ['rating', 'rating_imdb'])) ?? fallback.rating; + const status = + string(first(editorial, ['tmdb_status', 'status'])) ?? fallback.status; + const tmdbId = + integer(first(editorial, ['tmdb_id', 'tmdbId'])) ?? fallback.tmdbId; + const providerCategoryId = + string(first(identity, ['category_id', 'providerCategoryId'])) ?? + fallback.providerCategoryId; + const common = { + language, + title, + originalTitle, + plot, + releaseDate, + year: releaseYear(releaseDate) ?? fallback.year, + durationMinutes: + durationMinutes(editorial.filter(Boolean) as ProviderRecord[]) ?? + fallback.durationMinutes, + genres: providerGenres ?? fallback.genres, + rating, + status, + posterUrl, + backdropUrl, + tmdbId, + providerCategoryId, + cast: providerCast ?? fallback.cast, + creators: providerCreators ?? fallback.creators, + }; + + if (mediaKind === 'movie') { + return createMovieDownloadSnapshot(common); + } + if (!fallback.episode) { + return { + ...createMovieDownloadSnapshot(common), + mediaKind: 'series', + }; + } + return createSeriesEpisodeDownloadSnapshot({ + ...common, + episode: fallback.episode, + }); +} + +export function mergeSnapshotWithTmdb( + snapshot: DownloadMetadataSnapshot, + details: TmdbMovieDetails | TmdbTvDetails, + source: DownloadMetadataProviderSource +): DownloadMetadataSnapshot { + const provider = + source === 'stalker' + ? { + info: mergeStalkerInfoWithTmdb( + stalkerSeed(snapshot).info, + details, + snapshot.mediaKind === 'movie' ? 'movie' : 'tv' + ), + } + : snapshot.mediaKind === 'movie' + ? mergeVodInfoWithTmdb( + movieSeed(snapshot), + details as TmdbMovieDetails + ) + : mergeSerieInfoWithTmdb( + seriesSeed(snapshot), + details as TmdbTvDetails + ); + return mapProviderToDownloadSnapshot({ + source, + language: snapshot.language, + mediaKind: snapshot.mediaKind, + fallback: snapshot, + provider, + }); +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html new file mode 100644 index 000000000..b5caee4b4 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html @@ -0,0 +1,499 @@ +@if (detail(); as offlineDetail) { + + + + + {{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }} + + @if (metadata()?.year; as year) { + @if (positiveFinite(year)) { + {{ year }} + } + } + @if (durationLabel(); as duration) { + {{ duration }} + } + @for (genre of genres(); track genre) { + {{ genre }} + } + @if (metadata()?.rating; as rating) { + @if (positiveFinite(rating)) { + + {{ rating }} + + } + } + @if (metadata()?.status; as status) { + @if (status.trim()) { + + {{ status }} + + } + } + + + + @if (cast().length) { +
+ {{ 'XTREAM.ACTORS' | translate }} +
+ @for (person of cast(); track personTrackKey(person)) { + + @if (person.profileUrl) { + + } @else { + + } + {{ + person.name + }} + + } +
+
+ } + @if (creators().length) { +
+ {{ + 'XTREAM.DIRECTOR' | translate + }} +
+ @for ( + person of creators(); + track personTrackKey(person) + ) { + + @if (person.profileUrl) { + + } @else { + + } + {{ + person.name + }} + + } +
+
+ } + @if (movieFileSize(); as size) { +
+ + {{ + 'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE' + | translate: { size } + }} + +
+ } +
+ + + @if (offlineDetail.kind === 'movie') { + + } + + + + + @if (providerUnavailable()) { + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE' + | translate + }} + + } + + @if (offlineDetail.kind === 'movie') { + + + + + } + + + @if (offlineDetail.kind === 'series') { +
+
+ @for ( + season of offlineDetail.seasons; + track seasonTestId(season); + let seasonIndex = $index + ) { + + } +
+ + @if (selectedSeason(); as season) { +
+ @for ( + episode of season.episodes; + track episode.item.id + ) { +
+
+ + {{ + episodeCoordinate( + episode.seasonNumber, + episode.episodeNumber + ) + }} + + + {{ + episodeTitle( + episode.item, + episode.episodeMetadata?.title + ) + }} + +
+ @if ( + formatItemBytes(episode.item); + as size + ) { + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE' + | translate: { size } + }} + + } + + + {{ + 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' + | translate + }} + +
+
+
+ + + + + +
+
+ } +
+ } +
+ } +
+} @else { + + @if (redirectFailed()) { +
+ + +
+ } @else if (showNotFound()) { +
+

+ {{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }} +

+ +
+ } +
+} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss new file mode 100644 index 000000000..92b782d49 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss @@ -0,0 +1,203 @@ +:host { + display: block; + height: 100%; + min-height: 0; +} + +button, +.offline-detail__portal-action { + -webkit-app-region: no-drag; +} + +.offline-detail__available, +.offline-detail__episode-available { + display: inline-flex; + align-items: center; + gap: 6px; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.offline-detail__people-group { + min-width: 0; + max-width: 100%; +} + +.offline-detail__person { + cursor: default; + user-select: text; +} + +.offline-detail__portal-action { + display: inline-flex; +} + +.offline-detail__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.offline-detail__episodes { + display: block; + padding: 24px 0 40px; +} + +.offline-detail__season-tabs { + display: flex; + gap: 8px; + padding-block: 0 16px; + overflow-x: auto; + scrollbar-width: thin; +} + +.offline-detail__season-tab { + display: grid; + flex: 0 0 auto; + gap: 2px; + min-width: 132px; + padding: 10px 14px; + border: 1px solid var(--mat-sys-outline-variant, var(--tag-border)); + border-radius: var(--mat-sys-corner-medium, 10px); + background: var(--mat-sys-surface-container, var(--tag-bg)); + color: var(--mat-sys-on-surface, var(--text-primary)); + font: inherit; + text-align: start; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease; + + &:hover { + background: var(--mat-sys-surface-container-high, var(--tag-bg)); + } + + &--selected { + border-color: var( + --app-selection-border, + var(--mat-sys-primary, var(--text-primary)) + ); + background: var( + --app-selection-surface, + var(--mat-sys-secondary-container, var(--tag-bg)) + ); + color: var( + --app-selection-color, + var(--mat-sys-on-secondary-container, var(--text-primary)) + ); + box-shadow: + inset 0 0 0 1px var(--app-selection-border, var(--mat-sys-primary)), + 0 12px 20px -18px var(--app-selection-glow, var(--mat-sys-primary)); + + &:hover { + background: var( + --app-selection-surface-strong, + var(--mat-sys-secondary-container, var(--tag-bg)) + ); + } + } + + &:focus-visible { + outline: 2px solid + var(--app-selection-border, var(--mat-sys-primary, currentColor)); + outline-offset: 2px; + } +} + +.offline-detail__season-count { + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + font-size: 0.75rem; +} + +.offline-detail__episode-list { + display: grid; + gap: 8px; +} + +.offline-detail__episode { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 72px; + padding: 12px 12px 12px 16px; + border: 1px solid var(--mat-sys-outline-variant, var(--tag-border)); + border-radius: var(--mat-sys-corner-medium, 10px); + background: var(--mat-sys-surface-container, var(--surface-bg)); +} + +.offline-detail__episode-copy { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 4px 12px; + min-width: 0; +} + +.offline-detail__coordinate { + color: var(--mat-sys-primary, var(--text-primary)); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.offline-detail__episode-title { + overflow: hidden; + color: var(--mat-sys-on-surface, var(--text-primary)); + font-size: 0.9375rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.offline-detail__episode-facts { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + font-size: 0.75rem; +} + +.offline-detail__episode-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.offline-detail__not-found-actions { + display: grid; + justify-items: center; + gap: 12px; + color: var(--mat-sys-on-surface-variant, var(--text-secondary)); + + p { + margin: 0; + } +} + +@media (max-width: 700px) { + .offline-detail__episode { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + .offline-detail__episode-actions { + justify-content: flex-end; + } +} + +@media (prefers-reduced-motion: reduce) { + .offline-detail__season-tab { + transition: none; + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts new file mode 100644 index 000000000..ca73b0710 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts @@ -0,0 +1,1107 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + type DownloadItem, + DownloadsService, + SettingsStore, +} from '@iptvnator/services'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util'; +import { BehaviorSubject } from 'rxjs'; +import type { DownloadActionResult } from '../download-actions'; +import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; +import { DownloadManagerActionsService } from '../download-manager-actions.service'; +import { DownloadOfflineDetailComponent } from './download-offline-detail.component'; +import { DownloadOfflineFileCoordinatorService } from './download-offline-file-coordinator.service'; +import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service'; +import { DownloadOfflineMetadataService } from './download-offline-metadata.service'; +import { DownloadOfflineProviderCoordinatorService } from './download-offline-provider-coordinator.service'; +import { DownloadOfflineSeasonSelectionService } from './download-offline-season-selection.service'; +import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service'; + +const TRANSLATIONS = { + DOWNLOADS: { + ACTION_FAILED: 'Action failed', + MORE_ACTIONS: 'More actions', + REVEAL: 'Show in folder', + SEASON: 'Season {{season}}', + OFFLINE_DETAIL: { + AVAILABLE: 'Available offline', + PLAY: 'Play offline', + VIEW_IN_PORTAL: 'View in portal', + VIEW_IN_PORTAL_UNAVAILABLE: 'The source is unavailable', + DOWNLOADED_EPISODES: '{{count}} downloaded episodes', + FILE_SIZE: '{{size}}', + NOT_FOUND_TITLE: 'Download not found', + NOT_FOUND_BODY: 'This download is no longer in the manager.', + BACK: 'Back to Downloads', + }, + FILE_ACTION_ERROR: 'The file action could not be completed.', + FILE_NOT_FOUND: 'This downloaded file is no longer available on disk.', + RETRY: 'Retry', + }, + XTREAM: { + ACTORS: 'Cast', + DIRECTOR: 'Creators', + }, + SHOW_LESS: 'Show less', + SHOW_MORE: 'Show more', +} as const; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +interface DownloadsFake { + readonly downloads: ReturnType>; + readonly isLoadingDownloads: ReturnType>; + readonly hasLoadedDownloads: ReturnType>; + readonly loadDownloads: jest.Mock, []>; + readonly formatBytes: jest.Mock; +} + +interface ActionsFake { + readonly pendingIds: ReturnType>>; + readonly run: jest.Mock, [unknown]>; + readonly showActionError: jest.Mock; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function snapshot( + mediaKind: DownloadMetadataSnapshot['mediaKind'], + title: string, + overrides: Partial = {} +): DownloadMetadataSnapshot { + return { + version: 1, + language: 'en', + mediaKind, + title, + ...overrides, + }; +} + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1_000 + id, + contentType: 'vod', + title: `Download ${id}`, + url: `https://media.example.test/${id}`, + filePath: `/downloads/${id}.mp4`, + fileAvailability: 'available', + status: 'completed', + totalBytes: 1_024, + ...overrides, + }; +} + +const PROVIDER_TARGET: WorkspaceNavigationTarget = { + link: ['/workspace', 'xtreams', 'playlist-a', 'vod', '7', '1017'], +}; + +describe('DownloadOfflineDetailComponent', () => { + let fixture: ComponentFixture; + let routeParams: BehaviorSubject>; + let downloads: DownloadsFake; + let actions: ActionsFake; + let navigation: { + resolveProviderTarget: jest.Mock< + Promise, + [DownloadItem] + >; + navigateResolvedTarget: jest.Mock< + Promise, + [WorkspaceNavigationTarget] + >; + }; + let metadata: { + resolve: jest.Mock, [unknown]>; + }; + let router: { + url: string; + navigate: jest.Mock; + navigateByUrl: jest.Mock; + }; + let historyState: Record; + let location: { back: jest.Mock; getState: jest.Mock }; + + beforeEach(async () => { + routeParams = new BehaviorSubject( + convertToParamMap({ downloadId: '17' }) + ); + downloads = { + downloads: signal([]), + isLoadingDownloads: signal(false), + hasLoadedDownloads: signal(true), + loadDownloads: jest.fn().mockResolvedValue(undefined), + formatBytes: jest.fn((bytes: number) => `${bytes / 1_024} KB`), + }; + actions = { + pendingIds: signal>(new Set()), + run: jest.fn().mockResolvedValue('success'), + showActionError: jest.fn(), + }; + navigation = { + resolveProviderTarget: jest.fn().mockResolvedValue(PROVIDER_TARGET), + navigateResolvedTarget: jest.fn().mockResolvedValue(true), + }; + metadata = { + resolve: jest.fn(async (detail: unknown) => { + const value = detail as { + kind: 'movie' | 'series'; + item?: DownloadItem; + representative?: DownloadItem; + snapshot?: DownloadMetadataSnapshot; + }; + const item = value.item ?? value.representative; + return ( + value.snapshot ?? + snapshot(value.kind, item?.title ?? 'Offline download') + ); + }), + }; + router = { + url: '/workspace/downloads/17', + navigate: jest.fn().mockResolvedValue(true), + navigateByUrl: jest.fn().mockResolvedValue(true), + }; + historyState = {}; + location = { + back: jest.fn(), + getState: jest.fn(() => historyState), + }; + + const activatedRoute = { + paramMap: routeParams.asObservable(), + snapshot: { + paramMap: routeParams.value, + params: { downloadId: '17' }, + }, + }; + + await TestBed.configureTestingModule({ + imports: [ + DownloadOfflineDetailComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: Location, useValue: location }, + { + provide: DownloadsService, + useValue: downloads, + }, + { + provide: SettingsStore, + useValue: { language: signal('en') }, + }, + ], + }) + .overrideComponent(DownloadOfflineDetailComponent, { + set: { + providers: [ + { + provide: DownloadManagerActionsService, + useValue: actions, + }, + { + provide: DownloadLibraryNavigationService, + useValue: navigation, + }, + { + provide: DownloadOfflineMetadataService, + useValue: metadata, + }, + DownloadOfflineFileCoordinatorService, + DownloadOfflineMetadataResolutionService, + DownloadOfflineProviderCoordinatorService, + DownloadOfflineRouteNavigationService, + DownloadOfflineSeasonSelectionService, + ], + }, + }) + .compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', TRANSLATIONS); + translate.use('en'); + }); + + afterEach(() => fixture?.destroy()); + + async function render(items: DownloadItem[] = []): Promise { + downloads.downloads.set(items); + fixture = TestBed.createComponent(DownloadOfflineDetailComponent); + await fixture.whenStable(); + } + + function text(): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + function button(testId: string): HTMLButtonElement { + const selector = `[data-testid="${testId}"]`; + const element = + (fixture.nativeElement as HTMLElement).querySelector(selector) ?? + document.body.querySelector(selector); + expect(element).toBeTruthy(); + return element as HTMLButtonElement; + } + + it('loads downloads on entry and renders the focused loading state', async () => { + downloads.hasLoadedDownloads.set(false); + downloads.isLoadingDownloads.set(true); + + await render(); + + expect(downloads.loadDownloads).toHaveBeenCalledTimes(1); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'ngx-skeleton-loader' + ) + ).toBeTruthy(); + expect(text()).not.toContain('Download not found'); + }); + + it('renders persisted movie metadata immediately with row artwork fallback and local-only actions', async () => { + const movie = download(17, { + posterUrl: 'https://images.example.test/fallback.jpg', + totalBytes: 2_048, + metadataSnapshot: snapshot('movie', 'Signal', { + plot: 'A local-first mystery.', + year: 2026, + durationMinutes: 112, + genres: ['Drama', 'Mystery'], + rating: 8.4, + status: 'Released', + cast: [{ name: 'Ada Actor', role: 'Mara' }], + creators: [{ name: 'Cora Creator' }], + }), + }); + + await render([movie]); + + expect(text()).toContain('Signal'); + expect(text()).toContain('A local-first mystery.'); + expect(text()).toContain('Available offline'); + expect(text()).toContain('2026'); + expect(text()).toContain('112 min'); + expect(text()).toContain('Drama'); + expect(text()).toContain('8.4'); + expect(text()).toContain('Released'); + expect(text()).toContain('Ada Actor'); + expect(text()).toContain('Cora Creator'); + expect(text()).toContain('2 KB'); + expect(text()).not.toContain('{{size}}'); + expect(downloads.formatBytes).toHaveBeenCalledWith(2_048); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'img[src="https://images.example.test/fallback.jpg"]' + ) + ).toBeTruthy(); + expect(button('offline-play').textContent).toContain('Play offline'); + expect(button('view-in-portal').textContent).toContain( + 'View in portal' + ); + expect( + (fixture.nativeElement as HTMLElement) + .querySelector('.offline-detail__portal-action') + ?.hasAttribute('tabindex') + ).toBe(false); + expect(button('movie-overflow')).toBeTruthy(); + expect(text()).not.toContain('Play from source'); + }); + + it('omits sparse facts and does not format non-positive or non-finite file sizes', async () => { + await render([ + download(17, { + title: 'Sparse movie', + totalBytes: Number.NaN, + bytesDownloaded: 0, + metadataSnapshot: snapshot('movie', 'Sparse movie', { + durationMinutes: -1, + genres: [' '], + rating: Number.POSITIVE_INFINITY, + status: ' ', + year: Number.NaN, + }), + }), + ]); + + expect(text()).toContain('Sparse movie'); + expect(text()).not.toContain('File size'); + expect(text()).not.toContain('Infinity'); + expect(downloads.formatBytes).not.toHaveBeenCalled(); + }); + + it('renders only available series seasons and episodes in natural order with explicit local actions', async () => { + const seriesMetadata = snapshot('series', 'Northwind', { + plot: 'Signals travel north.', + }); + const episode = ( + id: number, + seasonNumber: number, + episodeNumber: number, + title: string, + overrides: Partial = {} + ) => + download(id, { + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber, + episodeNumber, + title, + metadataSnapshot: { + ...seriesMetadata, + episode: { seasonNumber, episodeNumber, title }, + }, + ...overrides, + }); + + await render([ + episode(17, 2, 1, 'Second season'), + episode(18, 1, 2, 'The Signal'), + episode(19, 1, 1, 'Arrival'), + episode(20, 1, 3, 'Missing', { + fileAvailability: 'missing', + }), + ]); + + expect(text()).toContain('Northwind'); + expect(button('view-in-portal')).toBeTruthy(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="offline-play"]' + ) + ).toBeNull(); + const seasons = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[data-testid^="offline-season-"]' + ) + ) as HTMLButtonElement[]; + expect(seasons).toHaveLength(2); + expect(seasons[0].getAttribute('aria-selected')).toBe('true'); + expect(seasons[0].tabIndex).toBe(0); + expect(seasons[1].tabIndex).toBe(-1); + expect(seasons[0].id).toBe('offline-season-1-tab'); + expect(seasons[0].getAttribute('aria-controls')).toBe( + 'offline-season-1-panel' + ); + expect( + (fixture.nativeElement as HTMLElement) + .querySelector('[role="tablist"]') + ?.getAttribute('aria-label') + ).toBe('2 downloaded episodes'); + expect(seasons[0].textContent).toContain('2 downloaded episodes'); + const firstSeasonEpisodes = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[data-testid^="offline-episode-"]' + ) + ) as HTMLElement[]; + expect( + firstSeasonEpisodes.map(({ dataset }) => dataset['testid']) + ).toEqual(['offline-episode-19', 'offline-episode-18']); + expect(firstSeasonEpisodes[0].textContent).toContain('S01E01'); + expect(firstSeasonEpisodes[0].textContent).toContain('Arrival'); + expect(firstSeasonEpisodes[0].textContent).toContain( + 'Available offline' + ); + expect(button('episode-play-19')).toBeTruthy(); + expect(button('episode-play-19').getAttribute('aria-label')).toBe( + 'Play offline: S01E01 Arrival' + ); + expect(button('episode-overflow-19')).toBeTruthy(); + button('episode-overflow-19').click(); + await fixture.whenStable(); + expect(button('episode-reveal-19').getAttribute('aria-label')).toBe( + 'Show in folder: S01E01 Arrival' + ); + + seasons[1].click(); + await fixture.whenStable(); + + expect(seasons[1].getAttribute('aria-selected')).toBe('true'); + expect(text()).toContain('S02E01'); + expect(text()).not.toContain('Missing'); + const panel = (fixture.nativeElement as HTMLElement).querySelector( + '[role="tabpanel"]' + ); + expect(panel?.getAttribute('aria-labelledby')).toBe( + 'offline-season-2-tab' + ); + }); + + it('resolves the provider target with parent metadata merged from an older series member', async () => { + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + const older = download(17, { + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber: 1, + episodeNumber: 1, + metadataSnapshot: snapshot('series', 'Northwind', { + enrichedAt: '2026-01-01T00:00:00.000Z', + providerCategoryId: '18', + }), + }); + const newest = download(18, { + contentType: 'episode', + seriesXtreamId: 77, + seasonNumber: 1, + episodeNumber: 2, + metadataSnapshot: snapshot('series', 'Northwind', { + enrichedAt: '2026-02-01T00:00:00.000Z', + }), + }); + + await render([older, newest]); + await Promise.resolve(); + fixture.detectChanges(); + + expect(navigation.resolveProviderTarget).toHaveBeenCalledWith( + expect.objectContaining({ + id: 18, + metadataSnapshot: expect.objectContaining({ + providerCategoryId: '18', + }), + }) + ); + }); + + it('moves season selection and focus with tablist keyboard controls', async () => { + const episode = (id: number, seasonNumber: number) => + download(id, { + contentType: 'episode', + episodeNumber: 1, + seasonNumber, + seriesXtreamId: 77, + title: `Northwind season ${seasonNumber}`, + }); + await render([episode(17, 1), episode(18, 2)]); + const tabs = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[role="tab"]' + ) + ) as HTMLButtonElement[]; + + for (const [key, expected] of [ + ['ArrowRight', 1], + ['ArrowLeft', 0], + ['ArrowDown', 1], + ['ArrowUp', 0], + ['End', 1], + ['Home', 0], + ] as const) { + const active = tabs().find( + (tab) => tab.getAttribute('aria-selected') === 'true' + ); + if (!active) throw new Error('Expected one selected season tab'); + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + active.dispatchEvent(event); + await fixture.whenStable(); + + expect(event.defaultPrevented).toBe(true); + expect(tabs()[expected].getAttribute('aria-selected')).toBe('true'); + expect(tabs()[expected].tabIndex).toBe(0); + expect(document.activeElement).toBe(tabs()[expected]); + } + }); + + it('persists the first-season fallback when a selected season disappears and later returns', async () => { + const episode = (id: number, seasonNumber: number) => + download(id, { + contentType: 'episode', + episodeNumber: 1, + seasonNumber, + seriesXtreamId: 77, + title: `Northwind season ${seasonNumber}`, + }); + const first = episode(17, 1); + const second = episode(18, 2); + await render([first, second]); + button('offline-season-2').click(); + await fixture.whenStable(); + button('offline-season-2').focus(); + expect(document.activeElement).toBe(button('offline-season-2')); + + downloads.downloads.set([first]); + await fixture.whenStable(); + expect(button('offline-season-1').getAttribute('aria-selected')).toBe( + 'true' + ); + expect(document.activeElement).toBe(button('offline-season-1')); + + downloads.downloads.set([first, second]); + await fixture.whenStable(); + expect(button('offline-season-1').getAttribute('aria-selected')).toBe( + 'true' + ); + expect(button('offline-season-2').getAttribute('aria-selected')).toBe( + 'false' + ); + }); + + it('normalizes season selection when the route is reused within one series', async () => { + const episode = (id: number, seasonNumber: number) => + download(id, { + contentType: 'episode', + episodeNumber: 1, + seasonNumber, + seriesXtreamId: 77, + title: `Northwind season ${seasonNumber}`, + }); + await render([episode(17, 1), episode(18, 2)]); + button('offline-season-2').click(); + await fixture.whenStable(); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + + expect(button('offline-season-1').getAttribute('aria-selected')).toBe( + 'true' + ); + }); + + it('shows a focused not-found state when the row is absent after the initial load', async () => { + await render(); + + expect(text()).toContain('Download not found'); + expect(text()).toContain('This download is no longer in the manager.'); + expect(button('not-found-back').textContent).toContain( + 'Back to Downloads' + ); + expect(router.navigate).not.toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('keeps View in portal visible but disabled and described when its source playlist is missing', async () => { + navigation.resolveProviderTarget.mockResolvedValueOnce(null); + + await render([download(17, { title: 'Orphaned movie' })]); + await Promise.resolve(); + fixture.detectChanges(); + + const viewButton = button('view-in-portal'); + expect(viewButton.disabled).toBe(true); + expect(viewButton.getAttribute('aria-describedby')).toBe( + 'offline-view-in-portal-unavailable' + ); + expect(text()).toContain('The source is unavailable'); + const wrapper = (fixture.nativeElement as HTMLElement).querySelector( + '.offline-detail__portal-action' + ) as HTMLElement; + expect(wrapper.getAttribute('tabindex')).toBe('0'); + expect(wrapper.getAttribute('aria-describedby')?.split(' ')).toContain( + 'offline-view-in-portal-unavailable' + ); + expect(wrapper.querySelector('button:not([disabled])')).toBeNull(); + expect(navigation.resolveProviderTarget).toHaveBeenCalledWith( + expect.objectContaining({ id: 17 }) + ); + }); + + it('keeps local playback available when provider resolution fails', async () => { + navigation.resolveProviderTarget.mockRejectedValueOnce( + new Error('playlist storage unavailable') + ); + + await render([download(17, { title: 'Offline survivor' })]); + await Promise.resolve(); + fixture.detectChanges(); + + expect(text()).toContain('Offline survivor'); + expect(button('offline-play').disabled).toBe(false); + expect(button('view-in-portal').disabled).toBe(true); + }); + + it('replace-navigates to the validated manager return URL when a completed file is initially missing', async () => { + historyState = { + navigationId: 4, + returnUrl: '/workspace/downloads?q=signal&filter=movie', + }; + + await render([ + download(17, { + fileAvailability: 'missing', + }), + ]); + + expect(router.navigateByUrl).toHaveBeenCalledWith( + '/workspace/downloads?q=signal&filter=movie', + { replaceUrl: true } + ); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it.each(['play', 'reveal'] as const)( + 'returns to the manager when a local %s race reloads the current file as missing', + async (actionType) => { + const operation = deferred(); + actions.run.mockReturnValueOnce(operation.promise); + await render([download(17), download(18, { title: 'Other' })]); + + button( + actionType === 'play' ? 'offline-play' : 'movie-overflow' + ).click(); + if (actionType === 'reveal') { + await fixture.whenStable(); + button('movie-reveal').click(); + } + actions.pendingIds.set(new Set([17])); + await fixture.whenStable(); + + expect(button('offline-play').disabled).toBe(true); + expect(button('view-in-portal').disabled).toBe(false); + downloads.downloads.set([ + download(17, { fileAvailability: 'missing' }), + download(18, { title: 'Other' }), + ]); + expect(router.navigate).not.toHaveBeenCalled(); + operation.resolve('file-missing'); + await fixture.whenStable(); + + expect(actions.run).toHaveBeenCalledWith( + expect.objectContaining({ + type: actionType, + item: expect.objectContaining({ id: 17 }), + }) + ); + expect(router.navigate).toHaveBeenCalledWith(['..'], { + relativeTo: expect.anything(), + queryParamsHandling: 'preserve', + replaceUrl: true, + }); + downloads.downloads.set([ + download(17, { fileAvailability: 'missing' }), + ]); + await fixture.whenStable(); + expect(router.navigate).toHaveBeenCalledTimes(1); + } + ); + + it('runs different episode file actions independently while deduplicating the same item', async () => { + const first = deferred(); + const second = deferred(); + actions.run + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const episode = (id: number, episodeNumber: number) => + download(id, { + contentType: 'episode', + episodeNumber, + seasonNumber: 1, + seriesXtreamId: 77, + title: `Northwind - S01E0${episodeNumber}`, + }); + await render([episode(17, 1), episode(18, 2)]); + + button('episode-play-17').click(); + button('episode-play-17').click(); + button('episode-play-18').click(); + + expect(actions.run).toHaveBeenCalledTimes(2); + expect( + actions.run.mock.calls.map(([action]) => action.item.id) + ).toEqual([17, 18]); + + second.resolve('success'); + first.resolve('success'); + await fixture.whenStable(); + }); + + it.each(['failed', 'success'] as const)( + 'shows the missing-file transition when an action ends with %s after its row disappears', + async (result) => { + const operation = deferred(); + actions.run.mockReturnValueOnce(operation.promise); + router.navigate.mockResolvedValueOnce(false); + await render([download(17)]); + + button('offline-play').click(); + downloads.downloads.set([ + download(17, { fileAvailability: 'missing' }), + ]); + await fixture.whenStable(); + expect(router.navigate).not.toHaveBeenCalled(); + + operation.resolve(result); + await fixture.whenStable(); + fixture.detectChanges(); + await Promise.resolve(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['..'], { + relativeTo: expect.anything(), + queryParamsHandling: 'preserve', + replaceUrl: true, + }); + expect(text()).toContain( + 'This downloaded file is no longer available on disk.' + ); + expect(button('redirect-retry')).toBeTruthy(); + } + ); + + it('does not let an older episode action redirect a reused route in the same series', async () => { + const operation = deferred(); + actions.run.mockReturnValueOnce(operation.promise); + const episode = (id: number, episodeNumber: number) => + download(id, { + contentType: 'episode', + episodeNumber, + seasonNumber: 1, + seriesXtreamId: 77, + title: `Northwind - S01E0${episodeNumber}`, + }); + await render([episode(17, 1), episode(18, 2)]); + + button('episode-play-17').click(); + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + operation.resolve('file-missing'); + await fixture.whenStable(); + + expect(text()).toContain('Northwind'); + expect(router.navigate).not.toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('renders a retryable missing-file error when the redirect returns false', async () => { + router.navigate + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + await render([download(17, { fileAvailability: 'missing' })]); + await Promise.resolve(); + fixture.detectChanges(); + + expect(text()).toContain( + 'This downloaded file is no longer available on disk.' + ); + expect(button('redirect-back').textContent).toContain( + 'Back to Downloads' + ); + button('redirect-retry').click(); + await fixture.whenStable(); + expect(router.navigate).toHaveBeenCalledTimes(2); + }); + + it('renders the retryable missing-file error when redirect navigation rejects', async () => { + router.navigate.mockRejectedValueOnce(new Error('router unavailable')); + + await render([download(17, { fileAvailability: 'missing' })]); + await Promise.resolve(); + fixture.detectChanges(); + + expect(text()).toContain( + 'This downloaded file is no longer available on disk.' + ); + expect(button('redirect-retry').textContent).toContain('Retry'); + }); + + it('ignores an older redirect failure after route reuse', async () => { + const redirect = deferred(); + router.navigate.mockReturnValueOnce(redirect.promise); + await render([download(17, { fileAvailability: 'missing' })]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + downloads.downloads.set([download(18, { title: 'Current route' })]); + await fixture.whenStable(); + redirect.resolve(false); + await fixture.whenStable(); + + expect(text()).toContain('Current route'); + expect(text()).not.toContain( + 'This downloaded file is no longer available on disk.' + ); + }); + + it('reacts to route reuse and displays the new row before metadata resolution completes', async () => { + const secondResolution = deferred(); + metadata.resolve + .mockResolvedValueOnce(snapshot('movie', 'Resolved first')) + .mockReturnValueOnce(secondResolution.promise); + await render([ + download(17, { title: 'Local first' }), + download(18, { title: 'Local second' }), + ]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + + expect(text()).toContain('Local second'); + expect(text()).not.toContain('Resolved first'); + expect(metadata.resolve).toHaveBeenCalledTimes(2); + }); + + it('discards an older asynchronous metadata result after a route-id change', async () => { + const firstResolution = deferred(); + const secondResolution = deferred(); + metadata.resolve + .mockReturnValueOnce(firstResolution.promise) + .mockReturnValueOnce(secondResolution.promise); + await render([ + download(17, { title: 'Local first' }), + download(18, { title: 'Local second' }), + ]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + secondResolution.resolve(snapshot('movie', 'Resolved second')); + await fixture.whenStable(); + firstResolution.resolve(snapshot('movie', 'Stale first')); + await fixture.whenStable(); + + expect(text()).toContain('Resolved second'); + expect(text()).not.toContain('Stale first'); + }); + + it('does not restart metadata resolution for unrelated download emissions', async () => { + const resolution = deferred(); + const current = download(17, { title: 'Stable local title' }); + const unrelated = download(18, { title: 'Other download' }); + metadata.resolve.mockReturnValueOnce(resolution.promise); + await render([current, unrelated]); + + downloads.downloads.set([ + { ...current, bytesDownloaded: 512 }, + { ...unrelated, bytesDownloaded: 256 }, + ]); + await fixture.whenStable(); + downloads.downloads.set([ + { ...current, bytesDownloaded: 768 }, + { ...unrelated, bytesDownloaded: 512 }, + ]); + await fixture.whenStable(); + + expect(metadata.resolve).toHaveBeenCalledTimes(1); + resolution.resolve(snapshot('movie', 'Stable resolved title')); + await fixture.whenStable(); + }); + + it('restarts metadata resolution when the current persisted snapshot changes', async () => { + const firstResolution = deferred(); + const secondResolution = deferred(); + const current = download(17, { title: 'Local title' }); + metadata.resolve + .mockReturnValueOnce(firstResolution.promise) + .mockReturnValueOnce(secondResolution.promise); + await render([current]); + + downloads.downloads.set([ + { + ...current, + metadataSnapshot: snapshot('movie', 'Persisted replacement'), + }, + ]); + await fixture.whenStable(); + + expect(metadata.resolve).toHaveBeenCalledTimes(2); + secondResolution.resolve(snapshot('movie', 'Fresh replacement')); + await fixture.whenStable(); + firstResolution.resolve(snapshot('movie', 'Stale result')); + await fixture.whenStable(); + expect(text()).toContain('Fresh replacement'); + expect(text()).not.toContain('Stale result'); + }); + + it('opens the current provider target and reports a navigation failure without blocking local playback', async () => { + navigation.navigateResolvedTarget.mockResolvedValueOnce(false); + await render([download(17)]); + + button('view-in-portal').click(); + await fixture.whenStable(); + + expect(navigation.navigateResolvedTarget).toHaveBeenCalledWith( + PROVIDER_TARGET + ); + expect(actions.showActionError).toHaveBeenCalledTimes(1); + expect(button('offline-play').disabled).toBe(false); + }); + + it('keeps the provider action disabled while its exact target is resolving', async () => { + const resolution = deferred(); + navigation.resolveProviderTarget.mockReturnValueOnce( + resolution.promise + ); + await render([download(17)]); + + expect(button('view-in-portal').disabled).toBe(true); + expect(button('view-in-portal').getAttribute('aria-busy')).toBe('true'); + expect(text()).not.toContain('The source is unavailable'); + + resolution.resolve(PROVIDER_TARGET); + await fixture.whenStable(); + fixture.detectChanges(); + expect(button('view-in-portal').disabled).toBe(false); + expect(button('view-in-portal').getAttribute('aria-busy')).toBeNull(); + }); + + it('uses one cached provider target and ignores duplicate open requests', async () => { + const opening = deferred(); + navigation.navigateResolvedTarget.mockReturnValueOnce(opening.promise); + await render([download(17)]); + + const first = fixture.componentInstance.viewInPortal(); + const duplicate = fixture.componentInstance.viewInPortal(); + + expect(navigation.resolveProviderTarget).toHaveBeenCalledTimes(1); + expect(navigation.navigateResolvedTarget).toHaveBeenCalledTimes(1); + await duplicate; + opening.resolve(true); + await first; + }); + + it('ignores a failed provider navigation after the route is reused while opening', async () => { + const opening = deferred(); + navigation.navigateResolvedTarget.mockReturnValueOnce(opening.promise); + await render([download(17), download(18, { title: 'Current route' })]); + + const oldRouteAction = fixture.componentInstance.viewInPortal(); + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + opening.resolve(false); + await oldRouteAction; + + expect(text()).toContain('Current route'); + expect(actions.showActionError).not.toHaveBeenCalled(); + }); + + it('does not enable or navigate a stale provider target after route reuse', async () => { + const first = deferred(); + const second = deferred(); + navigation.resolveProviderTarget + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + await render([download(17), download(18, { title: 'Current route' })]); + + routeParams.next(convertToParamMap({ downloadId: '18' })); + router.url = '/workspace/downloads/18'; + await fixture.whenStable(); + first.resolve(PROVIDER_TARGET); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(button('view-in-portal').disabled).toBe(true); + button('view-in-portal').click(); + expect(navigation.navigateResolvedTarget).not.toHaveBeenCalled(); + second.resolve(null); + await fixture.whenStable(); + }); + + it.each(['false', 'reject'] as const)( + 'clears provider pending state when navigation returns %s', + async (outcome) => { + if (outcome === 'false') { + navigation.navigateResolvedTarget.mockResolvedValueOnce(false); + } else { + navigation.navigateResolvedTarget.mockRejectedValueOnce( + new Error('router rejected') + ); + } + await render([download(17)]); + + button('view-in-portal').click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(button('view-in-portal').disabled).toBe(false); + expect( + button('view-in-portal').getAttribute('aria-busy') + ).toBeNull(); + expect(actions.showActionError).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + [ + 'global', + '/workspace/downloads?q=signal&filter=movie', + '/workspace/downloads/17', + ], + [ + 'Xtream-scoped', + '/workspace/xtreams/playlist-a/downloads?q=signal&filter=series', + '/workspace/xtreams/playlist-a/downloads/17', + ], + [ + 'Stalker-scoped', + '/workspace/stalker/playlist-a/downloads?q=signal&filter=in-progress', + '/workspace/stalker/playlist-a/downloads/17', + ], + ])( + 'uses browser Back for a validated %s manager return URL', + async (_scope, returnUrl, detailUrl) => { + historyState = { + navigationId: 9, + returnUrl, + }; + router.url = detailUrl; + await render([download(17)]); + + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement + ).click(); + await fixture.whenStable(); + + expect(location.back).toHaveBeenCalledTimes(1); + expect(router.navigate).not.toHaveBeenCalled(); + } + ); + + it('rejects an external return URL and falls back to the scoped parent safely', async () => { + historyState = { + navigationId: 9, + returnUrl: 'https://evil.example.test/workspace/downloads', + }; + router.url = '/workspace/stalker/playlist-a/downloads/17'; + await render([download(17)]); + + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement + ).click(); + await fixture.whenStable(); + + expect(location.back).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['..'], { + relativeTo: expect.anything(), + queryParamsHandling: 'preserve', + }); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts new file mode 100644 index 000000000..e55c22fc1 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts @@ -0,0 +1,290 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + PortalDetailShellComponent, +} from '@iptvnator/ui/components'; +import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; +import { DownloadManagerActionsService } from '../download-manager-actions.service'; +import { + buildDownloadOfflineDetail, + type DownloadOfflineSeason, +} from './download-offline-detail.viewmodel'; +import { + boundedOfflinePeople, + offlineDetailRepresentative, + offlineDurationLabel, + offlineEpisodeCoordinate, + offlineEpisodeCount, + offlineEpisodeTitle, + offlineFileByteCount, + offlineMetadataResolutionKey, + offlinePersonTrackKey, + offlinePositiveFinite, + offlineSeasonTestId, + type OfflineDetailItem, +} from './download-offline-detail.presentation'; +import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service'; +import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service'; +import { DownloadOfflineFileCoordinatorService } from './download-offline-file-coordinator.service'; +import { DownloadOfflineProviderCoordinatorService } from './download-offline-provider-coordinator.service'; +import { createDownloadOfflineRouteContext } from './download-offline-route-context'; +import { DownloadOfflineSeasonSelectionService } from './download-offline-season-selection.service'; + +@Component({ + selector: 'app-download-offline-detail', + imports: [ + DetailActionsTemplateDirective, + DetailMetaTemplateDirective, + DetailTagsTemplateDirective, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + PortalDetailShellComponent, + TranslatePipe, + ], + templateUrl: './download-offline-detail.component.html', + styleUrls: [ + '../../../../../../ui/components/src/lib/styles/detail-view.scss', + './download-offline-detail.component.scss', + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + DownloadLibraryNavigationService, + DownloadManagerActionsService, + DownloadOfflineFileCoordinatorService, + DownloadOfflineMetadataResolutionService, + DownloadOfflineProviderCoordinatorService, + DownloadOfflineRouteNavigationService, + DownloadOfflineSeasonSelectionService, + ], +}) +export class DownloadOfflineDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly downloadsService = inject(DownloadsService); + private readonly settings = inject(SettingsStore); + private readonly actions = inject(DownloadManagerActionsService); + private readonly metadataResolution = inject( + DownloadOfflineMetadataResolutionService + ); + private readonly routeNavigation = inject( + DownloadOfflineRouteNavigationService + ); + private readonly fileCoordinator = inject( + DownloadOfflineFileCoordinatorService + ); + private readonly providerCoordinator = inject( + DownloadOfflineProviderCoordinatorService + ); + private readonly seasonSelection = inject( + DownloadOfflineSeasonSelectionService + ); + + readonly pendingIds = this.actions.pendingIds; + readonly routeContext = createDownloadOfflineRouteContext(this.route); + readonly downloadId = computed(() => this.routeContext().downloadId); + readonly detail = computed(() => { + const downloadId = this.downloadId(); + return downloadId === undefined + ? undefined + : buildDownloadOfflineDetail({ + downloadId, + downloads: this.downloadsService.downloads(), + }); + }); + readonly metadataResolutionKey = computed(() => + offlineMetadataResolutionKey( + this.detail(), + this.downloadId(), + this.settings.language() + ) + ); + readonly currentItem = computed(() => + offlineDetailRepresentative(this.detail()) + ); + readonly providerItem = computed(() => { + const item = this.currentItem(); + const snapshot = this.detail()?.snapshot; + return item && snapshot + ? { ...item, metadataSnapshot: snapshot } + : item; + }); + readonly selectedRow = computed(() => + this.downloadsService + .downloads() + .find(({ id }) => id === this.downloadId()) + ); + readonly metadata = computed(() => { + const detail = this.detail(); + if (!detail) return undefined; + const resolved = this.metadataResolution.resolution(); + return resolved.key === this.metadataResolutionKey() + ? (resolved.snapshot ?? detail.snapshot) + : detail.snapshot; + }); + readonly title = computed( + () => this.metadata()?.title?.trim() || this.currentItem()?.title || '' + ); + readonly description = computed(() => this.metadata()?.plot?.trim()); + readonly posterUrl = computed( + () => + this.metadata()?.posterUrl?.trim() || + this.currentItem()?.posterUrl?.trim() + ); + readonly backdropUrl = computed(() => this.metadata()?.backdropUrl?.trim()); + readonly genres = computed(() => + (this.metadata()?.genres ?? []) + .map((genre) => genre.trim()) + .filter(Boolean) + .slice(0, 5) + ); + readonly cast = computed(() => boundedOfflinePeople(this.metadata()?.cast)); + readonly creators = computed(() => + boundedOfflinePeople(this.metadata()?.creators) + ); + readonly providerState = this.providerCoordinator.state; + readonly canOpenInPortal = computed( + () => this.providerState().status === 'available' + ); + readonly providerLoading = computed( + () => this.providerState().status === 'loading' + ); + readonly providerPending = computed( + () => this.providerState().status === 'opening' + ); + readonly providerUnavailable = computed( + () => this.providerState().status === 'unavailable' + ); + readonly showLoading = computed( + () => + !this.detail() && + (!this.downloadsService.hasLoadedDownloads() || + this.downloadsService.isLoadingDownloads()) + ); + readonly showNotFound = computed( + () => + !this.detail() && + !this.showLoading() && + (!this.selectedRow() || this.selectedRow()?.status !== 'completed') + ); + readonly redirectFailed = computed(() => + this.fileCoordinator.isRedirectFailed(this.routeContext()) + ); + readonly seasons = computed(() => { + const detail = this.detail(); + return detail?.kind === 'series' ? detail.seasons : []; + }); + readonly selectedSeason = computed(() => { + return this.seasonSelection.selected( + this.routeContext(), + this.seasons() + ); + }); + readonly count = computed(() => offlineEpisodeCount(this.selectedSeason())); + readonly movieFileSize = computed(() => { + const detail = this.detail(); + return detail?.kind === 'movie' + ? this.formatItemBytes(detail.item) + : undefined; + }); + readonly durationLabel = computed(() => + offlineDurationLabel(this.metadata()?.durationMinutes) + ); + readonly seasonTestId = offlineSeasonTestId; + readonly episodeCoordinate = offlineEpisodeCoordinate; + readonly episodeTitle = offlineEpisodeTitle; + readonly positiveFinite = offlinePositiveFinite; + readonly personTrackKey = offlinePersonTrackKey; + + constructor() { + void this.downloadsService.loadDownloads(); + this.metadataResolution.connect( + this.metadataResolutionKey, + this.detail + ); + this.providerCoordinator.connect(this.routeContext, this.providerItem); + this.seasonSelection.connect(this.routeContext, this.seasons); + this.fileCoordinator.connect({ + detail: this.detail, + hasLoaded: this.downloadsService.hasLoadedDownloads, + isLoading: this.downloadsService.isLoadingDownloads, + route: this.routeContext, + selectedRow: this.selectedRow, + }); + } + + selectSeason(season: DownloadOfflineSeason): void { + this.seasonSelection.select(this.routeContext(), season); + } + + isSelectedSeason(season: DownloadOfflineSeason): boolean { + return this.selectedSeason() === season; + } + + seasonTabId(season: DownloadOfflineSeason): string { + return this.seasonSelection.tabId(season); + } + + seasonTabIndex(season: DownloadOfflineSeason): number { + return this.isSelectedSeason(season) ? 0 : -1; + } + + onSeasonKeydown(event: KeyboardEvent, index: number): void { + this.seasonSelection.handleKeydown( + event, + index, + this.routeContext(), + this.seasons() + ); + } + + isPending(item: OfflineDetailItem): boolean { + return this.pendingIds().has(item.id); + } + + formatItemBytes(item: OfflineDetailItem): string | undefined { + const bytes = offlineFileByteCount(item); + return bytes === undefined + ? undefined + : this.downloadsService.formatBytes(bytes); + } + + async runFileAction( + type: 'play' | 'reveal', + item: OfflineDetailItem + ): Promise { + await this.fileCoordinator.runFileAction(type, item, this.routeContext); + } + + async viewInPortal(): Promise { + const result = await this.providerCoordinator.open( + this.routeContext(), + this.providerItem() + ); + if (result === 'failed') { + this.actions.showActionError(); + } + } + + goBack(): void { + this.routeNavigation.back(); + } + + async retryRedirect(): Promise { + await this.fileCoordinator.retry(this.routeContext()); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts new file mode 100644 index 000000000..697622078 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts @@ -0,0 +1,27 @@ +import { + boundedOfflinePeople, + offlinePersonTrackKey, +} from './download-offline-detail.presentation'; + +describe('offline detail people presentation', () => { + it('deduplicates people and supplies unique stable composite track keys', () => { + const people = boundedOfflinePeople([ + { tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' }, + { tmdbPersonId: 7, name: 'Ada Actor', role: 'Duplicate' }, + { tmdbPersonId: 8, name: 'Ada Actor', role: 'Lead' }, + { name: 'Ada Actor', role: 'Lead' }, + { name: 'Casey Guest', role: 'Pilot' }, + { name: 'Casey Guest', role: 'Pilot' }, + { name: 'Casey Guest', role: 'Captain' }, + ]); + + expect(people).toEqual([ + { tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' }, + { name: 'Casey Guest', role: 'Pilot' }, + { name: 'Casey Guest', role: 'Captain' }, + ]); + expect(new Set(people.map(offlinePersonTrackKey)).size).toBe( + people.length + ); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts new file mode 100644 index 000000000..de6946bec --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts @@ -0,0 +1,195 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { + DownloadMetadataPerson, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; +import type { + DeepReadonly, + DownloadOfflineDetail, + DownloadOfflineSeason, +} from './download-offline-detail.viewmodel'; + +export type OfflineDetailItem = DeepReadonly; + +export interface OfflineSelectedSeason { + readonly identity: string; + readonly key: string; +} + +export function parseOfflineDownloadId( + value: string | null +): number | undefined { + const normalized = value?.trim() ?? ''; + if (!/^\d+$/.test(normalized)) return undefined; + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +export function offlineDetailRepresentative( + detail: DownloadOfflineDetail | undefined +): OfflineDetailItem | undefined { + if (!detail) return undefined; + return detail.kind === 'movie' ? detail.item : detail.representative; +} + +export function offlineDetailIdentity( + detail: DownloadOfflineDetail | undefined +): string | undefined { + if (!detail) return undefined; + const item = offlineDetailRepresentative(detail); + if (!item) return undefined; + return detail.kind === 'series' + ? `series:${item.playlistId}:${item.seriesXtreamId ?? item.id}` + : `movie:${item.playlistId}:${item.id}`; +} + +export function offlineMetadataResolutionKey( + detail: DownloadOfflineDetail | undefined, + downloadId: number | undefined, + language: string +): string | undefined { + const item = offlineDetailRepresentative(detail); + if (!detail || !item || downloadId === undefined) return undefined; + const snapshot = detail.snapshot as DownloadMetadataSnapshot | undefined; + return JSON.stringify([ + detail.kind, + downloadId, + item.id, + item.playlistId, + item.xtreamId, + item.seriesXtreamId, + item.seasonNumber, + item.episodeNumber, + item.title, + item.posterUrl, + language.trim() || 'en', + snapshot, + ]); +} + +export function offlineSeasonKey(season: DownloadOfflineSeason): string { + return season.seasonNumberState === 'known' + ? `season-${season.seasonNumber}` + : 'season-unknown'; +} + +export function offlineSeasonTestId(season: DownloadOfflineSeason): string { + return `offline-${offlineSeasonKey(season)}`; +} + +export function offlineEpisodeCount( + season: DownloadOfflineSeason | undefined +): number { + return season?.episodes.length ?? 0; +} + +export function offlineEpisodeCoordinate( + seasonNumber: number | null, + episodeNumber: number | null +): string { + const coordinate = (prefix: string, value: number | null) => + `${prefix}${value === null ? '??' : String(value).padStart(2, '0')}`; + return `${coordinate('S', seasonNumber)}${coordinate('E', episodeNumber)}`; +} + +export function offlineEpisodeTitle( + item: OfflineDetailItem, + metadataTitle?: string +): string { + return metadataTitle?.trim() || item.title; +} + +export function offlineFileByteCount( + item: OfflineDetailItem +): number | undefined { + return [item.totalBytes, item.bytesDownloaded].find( + (value): value is number => + value !== undefined && Number.isFinite(value) && value > 0 + ); +} + +export function offlineDurationLabel( + duration: number | undefined +): string | undefined { + return duration !== undefined && Number.isFinite(duration) && duration > 0 + ? `${duration} min` + : undefined; +} + +export function offlinePositiveFinite(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +export function boundedOfflinePeople( + values: readonly DownloadMetadataPerson[] | undefined +): readonly DownloadMetadataPerson[] { + const people: DownloadMetadataPerson[] = []; + const seenProviderIds = new Set(); + const seenNames = new Set(); + for (const person of values ?? []) { + if (!person.name.trim()) continue; + const nameKey = offlinePersonNameKey(person); + const duplicate = + seenNames.has(nameKey) || + (person.tmdbPersonId !== undefined && + seenProviderIds.has(person.tmdbPersonId)); + seenNames.add(nameKey); + if (person.tmdbPersonId !== undefined) { + seenProviderIds.add(person.tmdbPersonId); + } + if (duplicate) continue; + people.push(person); + if (people.length === 12) break; + } + return people; +} + +export function offlinePersonTrackKey(person: DownloadMetadataPerson): string { + return [person.tmdbPersonId ?? 'local', offlinePersonNameKey(person)].join( + ':' + ); +} + +function offlinePersonNameKey(person: DownloadMetadataPerson): string { + return [ + person.name.trim().toLowerCase(), + person.role?.trim().toLowerCase() ?? '', + ].join(':'); +} + +export function offlineHasLocalFile( + item: OfflineDetailItem | undefined +): boolean { + return ( + item?.status === 'completed' && + !!item.filePath?.trim() && + item.fileAvailability !== 'missing' + ); +} + +export function safeOfflineManagerReturnUrl( + state: unknown, + currentUrl: string +): string | undefined { + if (!state || typeof state !== 'object') return undefined; + const record = state as Record; + const returnUrl = record['returnUrl']; + const navigationId = record['navigationId']; + if ( + typeof returnUrl !== 'string' || + typeof navigationId !== 'number' || + navigationId <= 1 || + !returnUrl.startsWith('/') || + returnUrl.startsWith('//') + ) { + return undefined; + } + const currentPath = currentUrl.split(/[?#]/, 1)[0].replace(/\/$/, ''); + const managerPath = currentPath.replace(/\/[^/]+$/, ''); + const returnPath = returnUrl.split(/[?#]/, 1)[0].replace(/\/$/, ''); + const managerPattern = + /^\/workspace\/(?:downloads|(?:xtreams|stalker)\/[^/?#]+\/downloads)$/; + return managerPattern.test(managerPath) && returnPath === managerPath + ? returnUrl + : undefined; +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts new file mode 100644 index 000000000..78f26a78a --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts @@ -0,0 +1,789 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { + DownloadEpisodeMetadata, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; +import { + buildDownloadOfflineDetail, + type DeepReadonly, + type DownloadOfflineDetail, + type DownloadOfflineEpisode, + type DownloadOfflineMovieDetail, +} from './download-offline-detail.viewmodel'; + +const SERIES_ID = 77; +const CREATED_AT = '2026-01-01T00:00:00.000Z'; + +function deepFreeze(value: T): T { + Object.freeze(value); + for (const child of Object.values(value as Record)) { + if ( + child !== null && + typeof child === 'object' && + !Object.isFrozen(child) + ) { + deepFreeze(child); + } + } + return value; +} + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1_000 + id, + contentType: 'episode', + seriesXtreamId: SERIES_ID, + seasonNumber: 1, + episodeNumber: 1, + title: `Download ${id}`, + url: `https://media.example.test/${id}`, + filePath: `/downloads/${id}.mp4`, + fileAvailability: 'available', + status: 'completed', + createdAt: CREATED_AT, + ...overrides, + }; +} + +function snapshot( + mediaKind: DownloadMetadataSnapshot['mediaKind'], + title: string, + overrides: Partial = {} +): DownloadMetadataSnapshot { + return { + version: 1, + language: 'en', + mediaKind, + title, + ...overrides, + }; +} + +function build( + downloadId: number, + downloads: readonly DownloadItem[] +): DownloadOfflineDetail | undefined { + return buildDownloadOfflineDetail({ downloadId, downloads }); +} + +function expectSeries( + detail: DownloadOfflineDetail | undefined +): Extract { + expect(detail?.kind).toBe('series'); + if (detail?.kind !== 'series') { + throw new Error('Expected an offline series detail'); + } + return detail; +} + +function assertReadonlyViewModelTypes( + episode: DownloadOfflineEpisode, + movie: DownloadOfflineMovieDetail +): void { + const item: DeepReadonly = episode.item; + void item; + + // @ts-expect-error View-model source items are recursively readonly. + episode.item.title = 'Mutated title'; + if (episode.item.metadataSnapshot?.genres) { + // @ts-expect-error Nested snapshot arrays are recursively readonly. + episode.item.metadataSnapshot.genres.push('Mutation'); + } + if (episode.episodeMetadata) { + // @ts-expect-error Per-episode metadata is recursively readonly. + episode.episodeMetadata.title = 'Mutated episode'; + } + if (movie.snapshot) { + // @ts-expect-error Detail snapshots are recursively readonly. + movie.snapshot.title = 'Mutated movie'; + } +} + +describe('buildDownloadOfflineDetail', () => { + it('exposes source data through recursive readonly view-model types', () => { + expect(assertReadonlyViewModelTypes).toBeDefined(); + }); + + it('resolves an available movie by its exact positive integer download id', () => { + const movieSnapshot = snapshot('movie', 'Stored movie'); + const selected = download(7, { + contentType: 'vod', + seriesXtreamId: undefined, + metadataSnapshot: movieSnapshot, + }); + const downloads = deepFreeze([ + download(6, { + contentType: 'vod', + seriesXtreamId: undefined, + }), + selected, + download(8, { + contentType: 'vod', + seriesXtreamId: undefined, + }), + ]); + + const detail = build(7, downloads); + + expect(detail).toEqual({ + kind: 'movie', + item: selected, + snapshot: movieSnapshot, + }); + expect(detail?.kind === 'movie' && detail.item).toBe(selected); + }); + + it.each([ + Number.NaN, + Number.NEGATIVE_INFINITY, + -1, + 0, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + Number.POSITIVE_INFINITY, + ])('rejects the invalid download id %p', (downloadId) => { + const downloads = deepFreeze([ + download(downloadId, { + contentType: 'vod', + seriesXtreamId: undefined, + }), + ]); + + expect(build(downloadId, downloads)).toBeUndefined(); + }); + + it.each([ + ['non-completed', { status: 'paused' as const }], + ['missing path', { filePath: undefined }], + ['empty path', { filePath: ' ' }], + ['explicitly missing file', { fileAvailability: 'missing' as const }], + ])('does not resolve a movie with %s', (_description, overrides) => { + const unavailable = download(9, { + contentType: 'vod', + seriesXtreamId: undefined, + ...overrides, + }); + + expect(build(9, deepFreeze([unavailable]))).toBeUndefined(); + }); + + it('keeps legacy completed rows available when fileAvailability is absent', () => { + const legacy = download(9, { + contentType: 'vod', + seriesXtreamId: undefined, + fileAvailability: undefined, + }); + + expect(build(9, deepFreeze([legacy]))).toEqual({ + kind: 'movie', + item: legacy, + }); + }); + + it('uses only a movie snapshot for a movie detail', () => { + const wrongKind = snapshot('series', 'Not movie metadata'); + const movie = download(9, { + contentType: 'vod', + seriesXtreamId: undefined, + metadataSnapshot: wrongKind, + }); + + expect(build(9, deepFreeze([movie]))).toEqual({ + kind: 'movie', + item: movie, + }); + }); + + it('groups only locally available members with the exact series identity', () => { + const representative = download(10, { + seasonNumber: 1, + episodeNumber: 5, + }); + const seasonOneEpisodeTwo = download(11, { + seasonNumber: 1, + episodeNumber: 2, + }); + const missing = download(12, { + seasonNumber: 1, + episodeNumber: 3, + fileAvailability: 'missing', + }); + const seasonTwoEpisodeOne = download(13, { + seasonNumber: 2, + episodeNumber: 1, + }); + const downloads = deepFreeze([ + representative, + seasonOneEpisodeTwo, + missing, + seasonTwoEpisodeOne, + download(14, { + playlistId: 'playlist-b', + seasonNumber: 1, + episodeNumber: 1, + }), + download(15, { + seriesXtreamId: SERIES_ID + 1, + seasonNumber: 1, + episodeNumber: 1, + }), + download(16, { + contentType: 'vod', + seasonNumber: 1, + episodeNumber: 1, + }), + download(17, { + status: 'failed', + seasonNumber: 1, + episodeNumber: 4, + }), + download(18, { + filePath: '', + seasonNumber: 2, + episodeNumber: 2, + }), + ]); + const before = JSON.stringify(downloads); + + const series = expectSeries(build(10, downloads)); + + expect(series.representative).toBe(representative); + expect(series.seasons.map(({ seasonNumber }) => seasonNumber)).toEqual([ + 1, 2, + ]); + expect( + series.seasons.map(({ episodes }) => + episodes.map(({ item }) => item.id) + ) + ).toEqual([[11, 10], [13]]); + expect(JSON.stringify(downloads)).toBe(before); + expect(downloads.map(({ id }) => id)).toEqual([ + 10, 11, 12, 13, 14, 15, 16, 17, 18, + ]); + }); + + it('allows any available group member to represent the same series', () => { + const first = download(10, { + seasonNumber: 1, + episodeNumber: 5, + }); + const second = download(11, { + seasonNumber: 1, + episodeNumber: 2, + }); + const downloads = deepFreeze([first, second]); + + const series = expectSeries(build(11, downloads)); + + expect(series.representative).toBe(second); + expect(series.seasons[0].episodes.map(({ item }) => item.id)).toEqual([ + 11, 10, + ]); + }); + + it('does not resolve an unavailable representative through available group members', () => { + const unavailableRepresentative = download(10, { + fileAvailability: 'missing', + }); + const availableMember = download(11); + const downloads = deepFreeze([ + unavailableRepresentative, + availableMember, + ]); + + expect(build(10, downloads)).toBeUndefined(); + expect(build(99, downloads)).toBeUndefined(); + }); + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects an episode with the invalid explicit series id %p', + (seriesXtreamId) => { + const invalid = download(20, { seriesXtreamId }); + + expect(build(20, deepFreeze([invalid]))).toBeUndefined(); + } + ); + + it('isolates a legacy episode without a series id as a one-episode series', () => { + const selected = download(21, { + seriesXtreamId: undefined, + seasonNumber: undefined, + episodeNumber: undefined, + }); + const unrelatedLegacyEpisode = download(22, { + seriesXtreamId: undefined, + seasonNumber: 4, + episodeNumber: 8, + }); + const downloads = deepFreeze([selected, unrelatedLegacyEpisode]); + + const series = expectSeries(build(21, downloads)); + + expect(series.representative).toBe(selected); + expect(series.seasons).toEqual([ + { + seasonNumber: null, + seasonNumberState: 'unknown', + episodes: [ + { + item: selected, + seasonNumber: null, + seasonNumberState: 'unknown', + episodeNumber: null, + episodeNumberState: 'unknown', + }, + ], + }, + ]); + }); + + it('distinguishes valid S0E0 specials from unknown coordinates', () => { + const special = download(23, { + seasonNumber: 0, + episodeNumber: 0, + }); + const sparse = download(24, { + seasonNumber: undefined, + episodeNumber: undefined, + }); + const invalid = download(25, { + seasonNumber: -2, + episodeNumber: 1.5, + }); + const downloads = deepFreeze([invalid, sparse, special]); + + const series = expectSeries(build(23, downloads)); + + expect( + series.seasons.map( + ({ seasonNumber, seasonNumberState, episodes }) => ({ + seasonNumber, + seasonNumberState, + episodes: episodes.map( + ({ + item, + seasonNumber: episodeSeasonNumber, + seasonNumberState: episodeSeasonNumberState, + episodeNumber, + episodeNumberState, + }) => ({ + id: item.id, + seasonNumber: episodeSeasonNumber, + seasonNumberState: episodeSeasonNumberState, + episodeNumber, + episodeNumberState, + }) + ), + }) + ) + ).toEqual([ + { + seasonNumber: 0, + seasonNumberState: 'known', + episodes: [ + { + id: 23, + seasonNumber: 0, + seasonNumberState: 'known', + episodeNumber: 0, + episodeNumberState: 'known', + }, + ], + }, + { + seasonNumber: null, + seasonNumberState: 'unknown', + episodes: [ + { + id: 24, + seasonNumber: null, + seasonNumberState: 'unknown', + episodeNumber: null, + episodeNumberState: 'unknown', + }, + { + id: 25, + seasonNumber: null, + seasonNumberState: 'unknown', + episodeNumber: null, + episodeNumberState: 'unknown', + }, + ], + }, + ]); + }); + + it('keeps multiple unknown episodes distinct and orders them by createdAt then id', () => { + const downloads = deepFreeze([ + download(63, { + seasonNumber: undefined, + episodeNumber: undefined, + createdAt: '2026-01-03T00:00:00.000Z', + }), + download(62, { + seasonNumber: Number.NaN, + episodeNumber: Number.POSITIVE_INFINITY, + createdAt: '2026-01-02T00:00:00.000Z', + }), + download(61, { + seasonNumber: -1, + episodeNumber: Number.MAX_SAFE_INTEGER + 1, + createdAt: '2026-01-02T00:00:00.000Z', + }), + ]); + + const series = expectSeries(build(63, downloads)); + + expect(series.seasons).toHaveLength(1); + expect(series.seasons[0]).toEqual( + expect.objectContaining({ + seasonNumber: null, + seasonNumberState: 'unknown', + }) + ); + expect( + series.seasons[0].episodes.map(({ item, episodeNumber }) => ({ + id: item.id, + episodeNumber, + })) + ).toEqual([ + { id: 61, episodeNumber: null }, + { id: 62, episodeNumber: null }, + { id: 63, episodeNumber: null }, + ]); + }); + + it('orders invalid or missing createdAt after valid timestamps and then by id', () => { + const downloads = deepFreeze([ + download(73, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: undefined, + }), + download(72, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: 'invalid', + }), + download(71, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: '2026-01-02T00:00:00.000Z', + }), + ]); + + const series = expectSeries(build(73, downloads)); + + expect(series.seasons[0].episodes.map(({ item }) => item.id)).toEqual([ + 71, 72, 73, + ]); + }); + + it('sorts unknown episodes after known episodes within a known season', () => { + const downloads = deepFreeze([ + download(28, { + seasonNumber: 1, + episodeNumber: undefined, + }), + download(27, { + seasonNumber: 1, + episodeNumber: 5, + }), + download(26, { + seasonNumber: 1, + episodeNumber: 2, + }), + ]); + + const series = expectSeries(build(28, downloads)); + + expect( + series.seasons[0].episodes.map( + ({ item, episodeNumber, episodeNumberState }) => ({ + id: item.id, + episodeNumber, + episodeNumberState, + }) + ) + ).toEqual([ + { id: 26, episodeNumber: 2, episodeNumberState: 'known' }, + { id: 27, episodeNumber: 5, episodeNumberState: 'known' }, + { id: 28, episodeNumber: null, episodeNumberState: 'unknown' }, + ]); + }); + + it('does not collapse known and unknown season groups', () => { + const downloads = deepFreeze([ + download(30, { + seasonNumber: undefined, + episodeNumber: 2, + }), + download(29, { + seasonNumber: 0, + episodeNumber: 3, + }), + ]); + + const series = expectSeries(build(30, downloads)); + + expect( + series.seasons.map(({ seasonNumber, seasonNumberState }) => ({ + seasonNumber, + seasonNumberState, + })) + ).toEqual([ + { seasonNumber: 0, seasonNumberState: 'known' }, + { seasonNumber: null, seasonNumberState: 'unknown' }, + ]); + }); + + it('sorts seasons and episodes numerically, then duplicate episodes by createdAt and id', () => { + const downloads = deepFreeze([ + download(35, { + seasonNumber: 2, + episodeNumber: 1, + }), + download(34, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: '2026-01-03T00:00:00.000Z', + }), + download(33, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: '2026-01-02T00:00:00.000Z', + }), + download(32, { + seasonNumber: 1, + episodeNumber: 4, + createdAt: '2026-01-02T00:00:00.000Z', + }), + download(31, { + seasonNumber: 1, + episodeNumber: 2, + }), + ]); + + const series = expectSeries(build(35, downloads)); + + expect( + series.seasons.map(({ seasonNumber, episodes }) => ({ + seasonNumber, + ids: episodes.map(({ item }) => item.id), + })) + ).toEqual([ + { seasonNumber: 1, ids: [31, 32, 33, 34] }, + { seasonNumber: 2, ids: [35] }, + ]); + }); + + it('selects snapshots by the newest per-row enrichedAt or timestamp fallback', () => { + const staleEnrichment = snapshot('series', 'Stale enrichment', { + enrichedAt: '2026-02-01T00:00:00.000Z', + }); + const newerRow = snapshot('series', 'Newer row'); + const wrongKind = snapshot('movie', 'Wrong kind', { + enrichedAt: '2027-01-01T00:00:00.000Z', + }); + const downloads = deepFreeze([ + download(40, { + metadataSnapshot: staleEnrichment, + updatedAt: '2026-12-01T00:00:00.000Z', + }), + download(41, { + metadataSnapshot: newerRow, + updatedAt: '2026-03-01T00:00:00.000Z', + }), + download(42, { + metadataSnapshot: wrongKind, + updatedAt: '2027-01-01T00:00:00.000Z', + }), + ]); + + const series = expectSeries(build(40, downloads)); + + expect(series.snapshot).toBe(newerRow); + }); + + it('fills missing parent metadata from older snapshots without replacing newer values', () => { + const older = snapshot('series', 'Older title', { + enrichedAt: '2026-01-01T00:00:00.000Z', + plot: 'Stored plot', + posterUrl: 'https://media.example.test/older-poster.jpg', + genres: ['Drama'], + rating: 8.2, + episode: { + title: 'Older episode', + seasonNumber: 1, + episodeNumber: 1, + }, + }); + const newest = snapshot('series', 'Newest title', { + enrichedAt: '2026-02-01T00:00:00.000Z', + backdropUrl: 'https://media.example.test/newest-backdrop.jpg', + rating: 9.1, + }); + const downloads = deepFreeze([ + download(60, { + episodeNumber: 1, + metadataSnapshot: older, + }), + download(61, { + episodeNumber: 2, + metadataSnapshot: newest, + }), + ]); + + const series = expectSeries(build(60, downloads)); + + expect(series.snapshot).toEqual({ + ...newest, + plot: older.plot, + posterUrl: older.posterUrl, + genres: older.genres, + }); + expect(series.snapshot?.rating).toBe(9.1); + expect(series.snapshot?.episode).toBeUndefined(); + expect(series.snapshot?.enrichedAt).toBe(newest.enrichedAt); + expect(newest).not.toHaveProperty('plot'); + }); + + it('falls through invalid snapshot and row timestamps before breaking ties by id', () => { + const first = snapshot('series', 'First', { + enrichedAt: 'invalid', + }); + const updatedFallback = snapshot('series', 'Updated fallback', { + enrichedAt: 'invalid', + }); + const createdFallback = snapshot('series', 'Created fallback', { + enrichedAt: 'invalid', + }); + const greaterId = snapshot('series', 'Greater id', { + enrichedAt: 'invalid', + }); + const downloads = deepFreeze([ + download(47, { + metadataSnapshot: first, + updatedAt: '2026-06-01T00:00:00.000Z', + }), + download(48, { + metadataSnapshot: updatedFallback, + updatedAt: '2026-06-05T00:00:00.000Z', + }), + download(49, { + metadataSnapshot: createdFallback, + updatedAt: 'invalid', + createdAt: '2026-06-06T00:00:00.000Z', + }), + download(50, { + metadataSnapshot: greaterId, + updatedAt: 'invalid', + createdAt: '2026-06-06T00:00:00.000Z', + }), + ]); + + const forward = expectSeries(build(47, downloads)); + const reversed = expectSeries( + build(47, deepFreeze([...downloads].reverse())) + ); + + expect(forward.snapshot).toBe(greaterId); + expect(reversed.snapshot).toBe(greaterId); + }); + + it('selects snapshots with entirely invalid timestamps deterministically by id', () => { + const lowerId = snapshot('series', 'Lower id', { + enrichedAt: 'invalid', + }); + const middleId = snapshot('series', 'Middle id', { + enrichedAt: 'invalid', + }); + const greaterId = snapshot('series', 'Greater id', { + enrichedAt: 'invalid', + }); + const downloads = deepFreeze([ + download(53, { + metadataSnapshot: lowerId, + updatedAt: 'invalid', + createdAt: 'invalid', + }), + download(54, { + metadataSnapshot: middleId, + updatedAt: undefined, + createdAt: undefined, + }), + download(55, { + metadataSnapshot: greaterId, + updatedAt: 'invalid', + createdAt: undefined, + }), + ]); + + const forward = expectSeries(build(53, downloads)); + const reversed = expectSeries( + build(53, deepFreeze([...downloads].reverse())) + ); + + expect(forward.snapshot).toBe(greaterId); + expect(reversed.snapshot).toBe(greaterId); + }); + + it('keeps each episode tied to its own snapshot episode metadata', () => { + const firstEpisode: DownloadEpisodeMetadata = { + title: 'Pilot', + plot: 'The first episode.', + stillUrl: 'https://media.example.test/pilot.jpg', + seasonNumber: 1, + episodeNumber: 1, + }; + const secondEpisode: DownloadEpisodeMetadata = { + title: 'Second episode', + seasonNumber: 1, + episodeNumber: 2, + }; + const firstSnapshot = snapshot('series', 'Series title', { + enrichedAt: '2026-01-01T00:00:00.000Z', + episode: firstEpisode, + }); + const secondSnapshot = snapshot('series', 'Series title', { + enrichedAt: '2026-02-01T00:00:00.000Z', + episode: secondEpisode, + }); + const first = download(51, { + episodeNumber: 1, + metadataSnapshot: firstSnapshot, + }); + const second = download(52, { + episodeNumber: 2, + metadataSnapshot: secondSnapshot, + }); + const downloads = deepFreeze([first, second]); + + const series = expectSeries(build(51, downloads)); + + expect(series.snapshot).toBe(secondSnapshot); + expect(series.seasons[0].episodes).toEqual([ + { + item: first, + seasonNumber: 1, + seasonNumberState: 'known', + episodeNumber: 1, + episodeNumberState: 'known', + episodeMetadata: firstEpisode, + }, + { + item: second, + seasonNumber: 1, + seasonNumberState: 'known', + episodeNumber: 2, + episodeNumberState: 'known', + episodeMetadata: secondEpisode, + }, + ]); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts new file mode 100644 index 000000000..dc117245b --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts @@ -0,0 +1,391 @@ +import type { DownloadItem } from '@iptvnator/services'; +import type { + DownloadEpisodeMetadata, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; + +type DeepReadonlyAtomic = + bigint | boolean | null | number | string | symbol | undefined; + +export type DeepReadonly = T extends DeepReadonlyAtomic + ? T + : T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +export type DownloadOfflineSeasonCoordinate = + | { + readonly seasonNumber: number; + readonly seasonNumberState: 'known'; + } + | { + readonly seasonNumber: null; + readonly seasonNumberState: 'unknown'; + }; + +export type DownloadOfflineEpisodeCoordinate = + | { + readonly episodeNumber: number; + readonly episodeNumberState: 'known'; + } + | { + readonly episodeNumber: null; + readonly episodeNumberState: 'unknown'; + }; + +export type DownloadOfflineEpisode = DownloadOfflineSeasonCoordinate & + DownloadOfflineEpisodeCoordinate & { + readonly item: DeepReadonly; + readonly episodeMetadata?: DeepReadonly; + }; + +export type DownloadOfflineSeason = DownloadOfflineSeasonCoordinate & { + readonly episodes: readonly DownloadOfflineEpisode[]; +}; + +export interface DownloadOfflineMovieDetail { + readonly kind: 'movie'; + readonly item: DeepReadonly; + readonly snapshot?: DeepReadonly; +} + +export interface DownloadOfflineSeriesDetail { + readonly kind: 'series'; + readonly representative: DeepReadonly; + readonly snapshot?: DeepReadonly; + readonly seasons: readonly DownloadOfflineSeason[]; +} + +export type DownloadOfflineDetail = + DownloadOfflineMovieDetail | DownloadOfflineSeriesDetail; + +export interface BuildDownloadOfflineDetailInput { + readonly downloadId: number; + readonly downloads: readonly DeepReadonly[]; +} + +type ReadonlyDownloadItem = DeepReadonly; + +type NormalizedCoordinate = + | { + readonly state: 'known'; + readonly value: number; + readonly sortBucket: 0; + } + | { + readonly state: 'unknown'; + readonly value: null; + readonly sortBucket: 1; + }; + +type NormalizedTimestamp = + | { + readonly state: 'known'; + readonly value: number; + readonly sortBucket: 0; + } + | { + readonly state: 'unknown'; + readonly sortBucket: 1; + }; + +interface SortableOfflineEpisode { + readonly view: DownloadOfflineEpisode; + readonly season: NormalizedCoordinate; + readonly episode: NormalizedCoordinate; + readonly createdAt: NormalizedTimestamp; +} + +function isPositiveSafeInteger(value: number | undefined): value is number { + return Number.isSafeInteger(value) && (value ?? 0) > 0; +} + +function isLocallyAvailable(item: ReadonlyDownloadItem): boolean { + return ( + item.status === 'completed' && + (item.filePath?.trim().length ?? 0) > 0 && + item.fileAvailability !== 'missing' + ); +} + +function parsedTimestamp(value: string | undefined): number | undefined { + const timestamp = Date.parse(value ?? ''); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function normalizedCoordinate(value: number | undefined): NormalizedCoordinate { + if (Number.isSafeInteger(value) && (value ?? -1) >= 0) { + return { state: 'known', value: value as number, sortBucket: 0 }; + } + return { state: 'unknown', value: null, sortBucket: 1 }; +} + +function normalizedTimestamp(value: string | undefined): NormalizedTimestamp { + const timestamp = parsedTimestamp(value); + return timestamp === undefined + ? { state: 'unknown', sortBucket: 1 } + : { state: 'known', value: timestamp, sortBucket: 0 }; +} + +function snapshotForKind( + item: ReadonlyDownloadItem, + mediaKind: DownloadMetadataSnapshot['mediaKind'] +): DeepReadonly | undefined { + const candidate = item.metadataSnapshot; + return candidate?.version === 1 && candidate.mediaKind === mediaKind + ? candidate + : undefined; +} + +function compareIdsNewestFirst(left: number, right: number): number { + if (left === right) { + return 0; + } + return right < left ? -1 : 1; +} + +function effectiveSeriesSnapshotTimestamp(item: ReadonlyDownloadItem): number { + const snapshot = snapshotForKind(item, 'series'); + for (const value of [ + snapshot?.enrichedAt, + item.updatedAt, + item.createdAt, + ]) { + const timestamp = parsedTimestamp(value); + if (timestamp !== undefined) { + return timestamp; + } + } + return 0; +} + +const SERIES_PARENT_METADATA_FIELDS = [ + 'originalTitle', + 'plot', + 'releaseDate', + 'year', + 'durationMinutes', + 'genres', + 'rating', + 'status', + 'posterUrl', + 'backdropUrl', + 'tmdbId', + 'providerCategoryId', + 'cast', + 'creators', +] as const satisfies readonly (keyof DownloadMetadataSnapshot)[]; + +function mergeMissingSeriesParentFields( + snapshots: readonly DeepReadonly[] +): DeepReadonly { + const newest = snapshots[0]; + let merged: Record | undefined; + + for (const field of SERIES_PARENT_METADATA_FIELDS) { + if (newest[field] !== undefined) { + continue; + } + const fallback = snapshots + .slice(1) + .map((snapshot) => snapshot[field]) + .find((value) => value !== undefined); + if (fallback !== undefined) { + merged ??= { ...newest }; + merged[field] = fallback; + } + } + + return ( + (merged as DeepReadonly | undefined) ?? newest + ); +} + +function selectSeriesSnapshot( + members: readonly ReadonlyDownloadItem[] +): DeepReadonly | undefined { + const candidates = members + .filter((item) => snapshotForKind(item, 'series') !== undefined) + .sort( + (left, right) => + effectiveSeriesSnapshotTimestamp(right) - + effectiveSeriesSnapshotTimestamp(left) || + compareIdsNewestFirst(left.id, right.id) + ); + + const snapshots = candidates + .map((item) => snapshotForKind(item, 'series')) + .filter( + (snapshot): snapshot is DeepReadonly => + snapshot !== undefined + ); + return snapshots.length === 0 + ? undefined + : mergeMissingSeriesParentFields(snapshots); +} + +function seasonCoordinateFields( + coordinate: NormalizedCoordinate +): DownloadOfflineSeasonCoordinate { + return coordinate.state === 'known' + ? { seasonNumber: coordinate.value, seasonNumberState: 'known' } + : { seasonNumber: null, seasonNumberState: 'unknown' }; +} + +function episodeCoordinateFields( + coordinate: NormalizedCoordinate +): DownloadOfflineEpisodeCoordinate { + return coordinate.state === 'known' + ? { episodeNumber: coordinate.value, episodeNumberState: 'known' } + : { episodeNumber: null, episodeNumberState: 'unknown' }; +} + +function toSortableOfflineEpisode( + item: ReadonlyDownloadItem +): SortableOfflineEpisode { + const season = normalizedCoordinate(item.seasonNumber); + const episode = normalizedCoordinate(item.episodeNumber); + const episodeMetadata = snapshotForKind(item, 'series')?.episode; + + return { + season, + episode, + createdAt: normalizedTimestamp(item.createdAt), + view: { + item, + ...seasonCoordinateFields(season), + ...episodeCoordinateFields(episode), + ...(episodeMetadata === undefined ? {} : { episodeMetadata }), + }, + }; +} + +function compareCoordinates( + left: NormalizedCoordinate, + right: NormalizedCoordinate +): number { + return ( + left.sortBucket - right.sortBucket || + (left.state === 'known' && right.state === 'known' + ? left.value - right.value + : 0) + ); +} + +function compareTimestamps( + left: NormalizedTimestamp, + right: NormalizedTimestamp +): number { + return ( + left.sortBucket - right.sortBucket || + (left.state === 'known' && right.state === 'known' + ? left.value - right.value + : 0) + ); +} + +function compareOfflineEpisodes( + left: SortableOfflineEpisode, + right: SortableOfflineEpisode +): number { + return ( + compareCoordinates(left.season, right.season) || + compareCoordinates(left.episode, right.episode) || + compareTimestamps(left.createdAt, right.createdAt) || + left.view.item.id - right.view.item.id + ); +} + +function buildSeasons( + members: readonly ReadonlyDownloadItem[] +): readonly DownloadOfflineSeason[] { + const episodes = members + .map(toSortableOfflineEpisode) + .sort(compareOfflineEpisodes); + const grouped = new Map< + number | null, + { + readonly coordinate: NormalizedCoordinate; + readonly episodes: DownloadOfflineEpisode[]; + } + >(); + + for (const episode of episodes) { + const season = grouped.get(episode.season.value); + if (season) { + season.episodes.push(episode.view); + } else { + grouped.set(episode.season.value, { + coordinate: episode.season, + episodes: [episode.view], + }); + } + } + + return [...grouped.values()].map(({ coordinate, episodes }) => ({ + ...seasonCoordinateFields(coordinate), + episodes, + })); +} + +function buildSeriesDetail( + representative: ReadonlyDownloadItem, + downloads: readonly ReadonlyDownloadItem[] +): DownloadOfflineSeriesDetail | undefined { + const seriesXtreamId = representative.seriesXtreamId; + if ( + seriesXtreamId !== undefined && + !isPositiveSafeInteger(seriesXtreamId) + ) { + return undefined; + } + + const members = + seriesXtreamId === undefined + ? [representative] + : downloads.filter( + (item) => + isPositiveSafeInteger(item.id) && + isLocallyAvailable(item) && + item.contentType === 'episode' && + item.playlistId === representative.playlistId && + item.seriesXtreamId === seriesXtreamId + ); + const snapshot = selectSeriesSnapshot(members); + + return { + kind: 'series', + representative, + ...(snapshot === undefined ? {} : { snapshot }), + seasons: buildSeasons(members), + }; +} + +export function buildDownloadOfflineDetail({ + downloadId, + downloads, +}: BuildDownloadOfflineDetailInput): DownloadOfflineDetail | undefined { + if (!isPositiveSafeInteger(downloadId)) { + return undefined; + } + + const representative = downloads.find(({ id }) => id === downloadId); + if (!representative || !isLocallyAvailable(representative)) { + return undefined; + } + + if (representative.contentType === 'vod') { + const snapshot = snapshotForKind(representative, 'movie'); + return { + kind: 'movie', + item: representative, + ...(snapshot === undefined ? {} : { snapshot }), + }; + } + + return buildSeriesDetail(representative, downloads); +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts new file mode 100644 index 000000000..36e4956cf --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts @@ -0,0 +1,175 @@ +import { effect, inject, Injectable, Injector, signal } from '@angular/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; +import type { OfflineDetailItem } from './download-offline-detail.presentation'; +import { DownloadManagerActionsService } from '../download-manager-actions.service'; +import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service'; + +export interface OfflineDetailRouteContext { + readonly downloadId?: number; + readonly generation: number; +} + +interface ActiveFileAction { + readonly actionGeneration: number; + readonly routeGeneration: number; +} + +interface RedirectState { + readonly routeGeneration: number; + readonly status: 'pending' | 'failed' | 'succeeded'; +} + +interface RedirectInputs { + readonly detail: () => DownloadOfflineDetail | undefined; + readonly hasLoaded: () => boolean; + readonly isLoading: () => boolean; + readonly route: () => OfflineDetailRouteContext; + readonly selectedRow: () => DownloadItem | undefined; +} + +@Injectable() +export class DownloadOfflineFileCoordinatorService { + private readonly actions = inject(DownloadManagerActionsService); + private readonly navigation = inject(DownloadOfflineRouteNavigationService); + private readonly injector = inject(Injector); + private readonly activeActions = signal< + ReadonlyMap + >(new Map()); + private readonly ownedRouteGeneration = signal( + undefined + ); + private readonly redirectState = signal( + undefined + ); + private actionGeneration = 0; + + connect(inputs: RedirectInputs): void { + effect( + () => { + const route = inputs.route(); + const row = inputs.selectedRow(); + const detail = inputs.detail(); + const routeHasActiveAction = this.hasActiveAction( + this.activeActions(), + route.generation + ); + if ( + detail && + !routeHasActiveAction && + this.ownedRouteGeneration() === route.generation + ) { + this.ownedRouteGeneration.set(undefined); + } + const unavailable = + route.downloadId !== undefined && + inputs.hasLoaded() && + !inputs.isLoading() && + row?.status === 'completed' && + !detail; + if ( + !unavailable || + routeHasActiveAction || + this.ownedRouteGeneration() === route.generation || + this.redirectState()?.routeGeneration === route.generation + ) { + return; + } + void this.redirect(route); + }, + { injector: this.injector } + ); + } + + isRedirectFailed(route: OfflineDetailRouteContext): boolean { + const state = this.redirectState(); + return ( + state?.routeGeneration === route.generation && + state.status === 'failed' + ); + } + + async retry(route: OfflineDetailRouteContext): Promise { + if (!this.isRedirectFailed(route)) return; + await this.redirect(route); + } + + async runFileAction( + type: 'play' | 'reveal', + item: OfflineDetailItem, + currentRoute: () => OfflineDetailRouteContext + ): Promise { + const route = currentRoute(); + if ( + this.activeActions().has(item.id) || + route.downloadId === undefined + ) { + return; + } + const active = { + actionGeneration: ++this.actionGeneration, + routeGeneration: route.generation, + }; + this.activeActions.update((actions) => + new Map(actions).set(item.id, active) + ); + this.ownedRouteGeneration.set(route.generation); + const result = await this.actions.run({ + type, + item: item as DownloadItem, + }); + const stillOwnsAction = this.activeActions().get(item.id) === active; + if (stillOwnsAction) { + this.activeActions.update((actions) => { + const next = new Map(actions); + next.delete(item.id); + return next; + }); + if ( + result !== 'file-missing' && + !this.hasActiveAction(this.activeActions(), route.generation) && + this.ownedRouteGeneration() === route.generation + ) { + this.ownedRouteGeneration.set(undefined); + } + } + if ( + !stillOwnsAction || + currentRoute().generation !== route.generation || + result !== 'file-missing' + ) { + return; + } + await this.redirect(route); + } + + private hasActiveAction( + actions: ReadonlyMap, + routeGeneration: number + ): boolean { + return Array.from(actions.values()).some( + (action) => action.routeGeneration === routeGeneration + ); + } + + private async redirect(route: OfflineDetailRouteContext): Promise { + const state = this.redirectState(); + if ( + state?.routeGeneration === route.generation && + state.status !== 'failed' + ) { + return; + } + const pending: RedirectState = { + routeGeneration: route.generation, + status: 'pending', + }; + this.redirectState.set(pending); + const succeeded = await this.navigation.toManager(true); + if (this.redirectState() !== pending) return; + this.redirectState.set({ + routeGeneration: route.generation, + status: succeeded ? 'succeeded' : 'failed', + }); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts new file mode 100644 index 000000000..77b5b6bc1 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts @@ -0,0 +1,80 @@ +import { + effect, + inject, + Injectable, + Injector, + signal, + type Signal, + untracked, +} from '@angular/core'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; +import { DownloadOfflineMetadataService } from './download-offline-metadata.service'; + +export interface OfflineMetadataResolution { + readonly generation: number; + readonly key?: string; + readonly snapshot?: DownloadMetadataSnapshot; +} + +@Injectable() +export class DownloadOfflineMetadataResolutionService { + private readonly metadata = inject(DownloadOfflineMetadataService); + private readonly injector = inject(Injector); + private readonly state = signal({ + generation: 0, + }); + private generation = 0; + private readonly inFlight = new Map< + string, + Promise + >(); + + readonly resolution = this.state.asReadonly(); + + connect( + key: Signal, + currentDetail: () => DownloadOfflineDetail | undefined + ): void { + effect( + () => { + const currentKey = key(); + const generation = ++this.generation; + this.state.set({ generation, key: currentKey }); + const detail = untracked(currentDetail); + if (!detail || !currentKey) return; + void this.resolveOnce(currentKey, detail) + .then((snapshot) => { + this.clearRequest(currentKey); + if ( + generation === this.generation && + currentKey === key() + ) { + this.state.set({ + generation, + key: currentKey, + snapshot, + }); + } + }) + .catch(() => this.clearRequest(currentKey)); + }, + { injector: this.injector } + ); + } + + private resolveOnce( + key: string, + detail: DownloadOfflineDetail + ): Promise { + const active = this.inFlight.get(key); + if (active) return active; + const request = this.metadata.resolve(detail); + this.inFlight.set(key, request); + return request; + } + + private clearRequest(key: string): void { + this.inFlight.delete(key); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts new file mode 100644 index 000000000..dcb21ce71 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.spec.ts @@ -0,0 +1,902 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { + DatabaseService, + DownloadsService, + PlaylistsService, + SettingsStore, + TMDB_DETAILS_CACHE_TTL_MS, + TmdbEnrichmentService, + type TmdbMovieDetails, + type TmdbTvDetails, + type XtreamContent, +} from '@iptvnator/services'; +import type { + DownloadMetadataSnapshot, + Playlist, + StalkerPortalItem, +} from '@iptvnator/shared/interfaces'; +import type { DownloadItem } from '@iptvnator/services'; +import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; +import { DownloadOfflineMetadataService } from './download-offline-metadata.service'; + +const PLAYLIST_ID = 'playlist-a'; +const NOW = '2026-07-31T08:30:00.000Z'; +const XTREAM_PLAYLIST = { + _id: PLAYLIST_ID, + title: 'Xtream source', + importDate: NOW, + lastUsage: NOW, + count: 0, + autoRefresh: false, + portalUrl: 'https://xtream.example.test', +} satisfies Playlist; +const STALKER_PLAYLIST = { + ...XTREAM_PLAYLIST, + title: 'Stalker source', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', +} satisfies Playlist; + +type DatabaseFake = jest.Mocked>; +type DownloadsFake = jest.Mocked>; +type PlaylistsFake = jest.Mocked< + Pick +>; +type TmdbFake = jest.Mocked< + Pick +>; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function providerContent( + title: string, + xtreamId: number, + type: 'movie' | 'series' = 'movie' +): XtreamContent { + return { + id: xtreamId, + category_id: 12, + title, + rating: '7', + added: '0', + poster_url: 'https://images.example.test/provider.jpg', + xtream_id: xtreamId, + type, + }; +} + +function download(overrides: Partial = {}): DownloadItem { + return { + id: 17, + playlistId: PLAYLIST_ID, + xtreamId: 41, + contentType: 'vod', + title: 'Downloaded fallback title', + url: 'https://stream.example.test/movie/41.m3u8', + posterUrl: 'https://images.example.test/download.jpg', + status: 'completed', + fileAvailability: 'available', + filePath: '/downloads/movie.mp4', + ...overrides, + }; +} + +function snapshot( + overrides: Partial = {} +): DownloadMetadataSnapshot { + return { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Local title', + plot: 'Rich local plot', + posterUrl: 'https://images.example.test/local.jpg', + providerCategoryId: '4', + enrichedAt: new Date().toISOString(), + ...overrides, + }; +} + +function movieDetail( + itemOverrides: Partial = {}, + snapshotOverride?: DownloadMetadataSnapshot +): DownloadOfflineDetail { + const item = download({ + ...itemOverrides, + ...(snapshotOverride === undefined + ? {} + : { metadataSnapshot: snapshotOverride }), + }); + return { + kind: 'movie', + item, + ...(snapshotOverride === undefined + ? {} + : { snapshot: snapshotOverride }), + }; +} + +function seriesDetail( + seriesSnapshot: DownloadMetadataSnapshot, + itemOverrides: Partial = {} +): DownloadOfflineDetail { + const representative = download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 93, + seasonNumber: 2, + episodeNumber: 7, + title: 'Series - S02E07 - Relay', + ...itemOverrides, + metadataSnapshot: seriesSnapshot, + }); + return { + kind: 'series', + representative, + snapshot: seriesSnapshot, + seasons: [ + { + seasonNumber: 2, + seasonNumberState: 'known', + episodes: [ + { + item: representative, + seasonNumber: 2, + seasonNumberState: 'known', + episodeNumber: 7, + episodeNumberState: 'known', + episodeMetadata: seriesSnapshot.episode, + }, + ], + }, + ], + }; +} + +const tmdbMovie: TmdbMovieDetails = { + id: 603, + overview: 'Localized TMDB movie plot', + poster_path: '/movie.jpg', +}; +const tmdbTv: TmdbTvDetails = { + id: 900, + overview: 'Localized TMDB series plot', + poster_path: '/series.jpg', +}; + +describe('DownloadOfflineMetadataService', () => { + let service: DownloadOfflineMetadataService; + let db: DatabaseFake; + let downloads: DownloadsFake; + let playlists: PlaylistsFake; + let tmdb: TmdbFake; + let currentLanguage: string; + + beforeEach(() => { + db = { + getContentByXtreamId: jest.fn().mockResolvedValue(null), + }; + downloads = { + updateMetadata: jest.fn().mockResolvedValue({ success: true }), + }; + playlists = { + getPlaylistById: jest.fn().mockReturnValue(of(XTREAM_PLAYLIST)), + getPortalRecentlyViewed: jest.fn().mockReturnValue(of([])), + }; + tmdb = { + isEnabled: jest.fn().mockReturnValue(false), + enrichMovie: jest.fn().mockResolvedValue(null), + enrichTv: jest.fn().mockResolvedValue(null), + }; + currentLanguage = 'en'; + + TestBed.configureTestingModule({ + providers: [ + DownloadOfflineMetadataService, + { provide: DatabaseService, useValue: db }, + { provide: DownloadsService, useValue: downloads }, + { provide: PlaylistsService, useValue: playlists }, + { provide: TmdbEnrichmentService, useValue: tmdb }, + { + provide: SettingsStore, + useValue: { language: () => currentLanguage }, + }, + ], + }); + service = TestBed.inject(DownloadOfflineMetadataService); + }); + + it('returns a rich fresh current-language snapshot without provider lookup', async () => { + const local = snapshot(); + + await expect(service.resolve(movieDetail({}, local))).resolves.toEqual( + local + ); + + expect(playlists.getPlaylistById).not.toHaveBeenCalled(); + expect(playlists.getPortalRecentlyViewed).not.toHaveBeenCalled(); + expect(db.getContentByXtreamId).not.toHaveBeenCalled(); + expect(tmdb.enrichMovie).not.toHaveBeenCalled(); + expect(downloads.updateMetadata).not.toHaveBeenCalled(); + }); + + it('recovers a sparse Stalker legacy row from its matching recent item', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + const recent = { + movie_id: '41:archive', + category_id: 'vod', + title: 'Stalker provider title', + cover: 'https://images.example.test/stalker-provider.jpg', + description: 'Stalker provider plot', + } satisfies StalkerPortalItem & { description: string }; + playlists.getPortalRecentlyViewed.mockReturnValue(of([recent])); + + const resolved = await service.resolve( + movieDetail({}, snapshot({ plot: undefined, posterUrl: undefined })) + ); + + expect(playlists.getPortalRecentlyViewed).toHaveBeenCalledWith( + PLAYLIST_ID + ); + expect(resolved.title).toBe('Stalker provider title'); + expect(resolved.plot).toBe('Stalker provider plot'); + expect(resolved.posterUrl).toBe( + 'https://images.example.test/stalker-provider.jpg' + ); + expect(db.getContentByXtreamId).not.toHaveBeenCalled(); + }); + + it('does not recover series metadata from a movie with an overlapping Stalker ID', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + of([ + { + movie_id: '93', + category_id: 'vod', + title: 'Wrong movie metadata', + }, + { + series_id: '93', + category_id: 'series', + title: 'Correct series metadata', + }, + ]) + ); + const local = snapshot({ + mediaKind: 'series', + title: 'Local series', + plot: undefined, + episode: { + title: 'Downloaded episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + + const resolved = await service.resolve(seriesDetail(local)); + + expect(resolved.title).toBe('Correct series metadata'); + expect(resolved.title).not.toBe('Wrong movie metadata'); + }); + + it('recovers a sparse Xtream row through the database content lookup', async () => { + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: 'Xtream provider title', + rating: '7.5', + added: '0', + poster_url: 'https://images.example.test/xtream-provider.jpg', + xtream_id: 41, + type: 'movie', + }); + + const resolved = await service.resolve(movieDetail()); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 41, + PLAYLIST_ID, + 'movie' + ); + expect(resolved.title).toBe('Xtream provider title'); + expect(resolved.providerCategoryId).toBe('12'); + expect(resolved.posterUrl).toBe( + 'https://images.example.test/xtream-provider.jpg' + ); + expect(playlists.getPortalRecentlyViewed).not.toHaveBeenCalled(); + }); + + it('uses the series discriminator for an offline episode lookup', async () => { + const local = snapshot({ + mediaKind: 'series', + plot: undefined, + episode: { + title: 'Downloaded episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + + await service.resolve(seriesDetail(local)); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 93, + PLAYLIST_ID, + 'series' + ); + }); + + it.each([ + ['movie', 'enrichMovie'] as const, + ['series', 'enrichTv'] as const, + ])('uses %s TMDB enrichment when enabled', async (kind, method) => { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue(tmdbMovie); + tmdb.enrichTv.mockResolvedValue(tmdbTv); + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: `Provider ${kind}`, + rating: '', + added: '0', + poster_url: '', + xtream_id: kind === 'movie' ? 41 : 93, + type: kind, + }); + const localSeries = snapshot({ + mediaKind: 'series', + title: 'Local series', + plot: undefined, + episode: { + title: 'Local episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + const detail = + kind === 'movie' ? movieDetail() : seriesDetail(localSeries); + + const resolved = await service.resolve(detail); + + expect(tmdb[method]).toHaveBeenCalledWith( + expect.objectContaining({ title: `Provider ${kind}` }) + ); + expect(resolved.plot).toBe( + kind === 'movie' + ? 'Localized TMDB movie plot' + : 'Localized TMDB series plot' + ); + }); + + it('keeps provider metadata when TMDB is disabled', async () => { + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: 'Provider title', + rating: '7', + added: '0', + poster_url: 'https://images.example.test/provider.jpg', + xtream_id: 41, + type: 'movie', + }); + + const resolved = await service.resolve(movieDetail()); + + expect(resolved.title).toBe('Provider title'); + expect(resolved.posterUrl).toBe( + 'https://images.example.test/provider.jpg' + ); + expect(tmdb.enrichMovie).not.toHaveBeenCalled(); + }); + + it('keeps provider metadata when TMDB rejects', async () => { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue(new Error('TMDB unavailable')); + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: 'Provider title', + rating: '7', + added: '0', + poster_url: 'https://images.example.test/provider.jpg', + xtream_id: 41, + type: 'movie', + }); + + await expect(service.resolve(movieDetail())).resolves.toEqual( + expect.objectContaining({ + title: 'Provider title', + posterUrl: 'https://images.example.test/provider.jpg', + }) + ); + }); + + it('keeps Stalker merge precedence when recent lookup fails', async () => { + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); + playlists.getPortalRecentlyViewed.mockReturnValue( + throwError(() => new Error('recent unavailable')) + ); + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue({ + id: 603, + overview: 'Localized TMDB plot', + vote_average: 7.2, + vote_count: 100, + }); + const local = snapshot({ plot: undefined, rating: 9.1 }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(resolved.plot).toBe('Localized TMDB plot'); + expect(resolved.rating).toBe(9.1); + expect(db.getContentByXtreamId).not.toHaveBeenCalled(); + }); + + it('labels a provider-missing successful TMDB refresh with the current language', async () => { + currentLanguage = 'de'; + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue({ + id: 603, + overview: 'Lokalisierte TMDB-Beschreibung', + }); + const local = snapshot({ language: 'en' }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(db.getContentByXtreamId).toHaveBeenCalledWith( + 41, + PLAYLIST_ID, + 'movie' + ); + expect(resolved.language).toBe('de'); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + expect.objectContaining({ language: 'de' }) + ); + }); + + it('does not relabel a mismatched snapshot when no refresh succeeds', async () => { + currentLanguage = 'de'; + const local = snapshot({ language: 'en' }); + + await expect(service.resolve(movieDetail({}, local))).resolves.toEqual( + local + ); + expect(downloads.updateMetadata).not.toHaveBeenCalled(); + }); + + it('omits credential-bearing provider artwork before persistence', async () => { + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: 'Recovered provider title', + rating: '7', + added: '0', + poster_url: + 'https://images.example.test/authorization/value/poster.jpg', + xtream_id: 41, + type: 'movie', + }); + const local = snapshot({ plot: undefined, posterUrl: undefined }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(resolved.posterUrl).toBeUndefined(); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + expect.not.objectContaining({ posterUrl: expect.anything() }) + ); + }); + + it('persists one materially changed successful merge', async () => { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue(tmdbMovie); + const local = snapshot({ plot: undefined }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith(17, resolved); + }); + + it('persists a materially identical successful TMDB refresh once', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue({ id: 603 }); + const local = snapshot({ + tmdbId: 603, + rating: 0, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + + expect(refreshed.enrichedAt).toBe(NOW); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + refreshed + ); + + await service.resolve(movieDetail({}, refreshed)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('throttles a successful sparse provider refresh per language during reload', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + db.getContentByXtreamId.mockResolvedValue({ + ...providerContent('Local title', 41), + category_id: 4, + poster_url: '', + }); + const local = snapshot({ + plot: undefined, + posterUrl: undefined, + backdropUrl: undefined, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + downloads.updateMetadata.mockImplementationOnce( + async (_downloadId, refreshed) => { + await service.resolve(movieDetail({}, refreshed)); + return { success: true }; + } + ); + + const refreshed = await service.resolve(movieDetail({}, local)); + + expect(refreshed.plot).toBeUndefined(); + expect(refreshed.posterUrl).toBeUndefined(); + expect(db.getContentByXtreamId).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + + currentLanguage = 'de'; + const translated = await service.resolve( + movieDetail({}, refreshed) + ); + + expect(translated.language).toBe('de'); + expect(db.getContentByXtreamId).toHaveBeenCalledTimes(2); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('throttles a successful sparse materially identical TMDB refresh', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockResolvedValue({ id: 603 }); + const local = snapshot({ + plot: undefined, + posterUrl: undefined, + backdropUrl: undefined, + tmdbId: 603, + rating: 0, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + await service.resolve(movieDetail({}, refreshed)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('retries a sparse transient TMDB failure', async () => { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue(new Error('TMDB unavailable')); + const local = snapshot({ + plot: undefined, + posterUrl: undefined, + backdropUrl: undefined, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + await service.resolve(movieDetail({}, local)); + await service.resolve(movieDetail({}, local)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(2); + expect(downloads.updateMetadata).not.toHaveBeenCalled(); + }); + + it('expires a completed sparse refresh throttle with the TMDB TTL', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + db.getContentByXtreamId.mockResolvedValue({ + ...providerContent('Local title', 41), + category_id: 4, + poster_url: '', + }); + const local = snapshot({ + plot: undefined, + posterUrl: undefined, + backdropUrl: undefined, + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + await service.resolve(movieDetail({}, refreshed)); + jest.advanceTimersByTime(TMDB_DETAILS_CACHE_TTL_MS + 1); + await service.resolve(movieDetail({}, refreshed)); + + expect(db.getContentByXtreamId).toHaveBeenCalledTimes(2); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('persists provider metadata without marking a failed TMDB refresh current', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + currentLanguage = 'de'; + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue( + new Error('TMDB temporarily unavailable') + ); + db.getContentByXtreamId.mockResolvedValue( + providerContent('Recovered provider title', 41) + ); + const local = snapshot({ + language: 'en', + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const resolved = await service.resolve(movieDetail({}, local)); + + expect(resolved.title).toBe('Recovered provider title'); + expect(resolved.language).toBe('en'); + expect(resolved.enrichedAt).toBe(local.enrichedAt); + expect(downloads.updateMetadata).toHaveBeenCalledWith(17, resolved); + + await service.resolve(movieDetail({}, resolved)); + + expect(tmdb.enrichMovie).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('marks a successful provider-only refresh fresh when TMDB is disabled', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(NOW)); + try { + db.getContentByXtreamId.mockResolvedValue({ + ...providerContent('Local title', 41), + category_id: 4, + rating: '', + poster_url: 'https://images.example.test/local.jpg', + }); + const local = snapshot({ + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + const refreshed = await service.resolve(movieDetail({}, local)); + + expect(refreshed.enrichedAt).toBe(NOW); + expect(downloads.updateMetadata).toHaveBeenCalledWith( + 17, + refreshed + ); + + await service.resolve(movieDetail({}, refreshed)); + + expect(db.getContentByXtreamId).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it('retains stale language and freshness when provider and TMDB fail', async () => { + currentLanguage = 'de'; + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichMovie.mockRejectedValue(new Error('TMDB unavailable')); + db.getContentByXtreamId.mockRejectedValue( + new Error('Provider unavailable') + ); + const local = snapshot({ + language: 'en', + enrichedAt: '2020-01-01T00:00:00.000Z', + }); + + await expect(service.resolve(movieDetail({}, local))).resolves.toEqual( + local + ); + expect(downloads.updateMetadata).not.toHaveBeenCalled(); + }); + + it('preserves local episode metadata and does not fabricate a series episode list', async () => { + tmdb.isEnabled.mockReturnValue(true); + tmdb.enrichTv.mockResolvedValue({ + ...tmdbTv, + seasons: [{ season_number: 1, episode_count: 99 }], + episodes: [{ season_number: 1, episode_number: 1 }], + } as TmdbTvDetails & Record); + const local = snapshot({ + mediaKind: 'series', + title: 'Local series', + plot: undefined, + episode: { + title: 'Downloaded episode', + plot: 'Local episode plot', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + + const resolved = await service.resolve(seriesDetail(local)); + + expect(resolved.episode).toEqual(local.episode); + expect(resolved).not.toHaveProperty('episodes'); + expect(resolved).not.toHaveProperty('seasons'); + }); + + it('does not fabricate episode coordinates for a sparse legacy series row', async () => { + const representative = download({ + contentType: 'episode', + xtreamId: 501, + seriesXtreamId: 93, + title: 'Legacy series episode', + }); + const detail: DownloadOfflineDetail = { + kind: 'series', + representative, + seasons: [ + { + seasonNumber: null, + seasonNumberState: 'unknown', + episodes: [ + { + item: representative, + seasonNumber: null, + seasonNumberState: 'unknown', + episodeNumber: null, + episodeNumberState: 'unknown', + }, + ], + }, + ], + }; + + const resolved = await service.resolve(detail); + + expect(resolved.mediaKind).toBe('series'); + expect(resolved).not.toHaveProperty('episode'); + }); + + it('does not persist an older movie resolve after a newer generation', async () => { + const olderProvider = deferred(); + const newerProvider = deferred(); + db.getContentByXtreamId + .mockImplementationOnce(() => olderProvider.promise) + .mockImplementationOnce(() => newerProvider.promise); + const local = snapshot({ enrichedAt: '2020-01-01T00:00:00.000Z' }); + const detail = movieDetail({}, local); + + const olderResolve = service.resolve(detail); + const newerResolve = service.resolve(detail); + newerProvider.resolve(providerContent('Newer provider title', 41)); + const newerResult = await newerResolve; + olderProvider.resolve(providerContent('Older provider title', 41)); + const olderResult = await olderResolve; + + expect(newerResult.title).toBe('Newer provider title'); + expect(olderResult.title).toBe('Older provider title'); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith(17, newerResult); + }); + + it('guards persistence across different episodes in one series group', async () => { + const olderProvider = deferred(); + const newerProvider = deferred(); + db.getContentByXtreamId + .mockImplementationOnce(() => olderProvider.promise) + .mockImplementationOnce(() => newerProvider.promise); + const olderSnapshot = snapshot({ + mediaKind: 'series', + title: 'Local series', + enrichedAt: '2020-01-01T00:00:00.000Z', + episode: { + title: 'Older episode', + seasonNumber: 2, + episodeNumber: 7, + }, + }); + const newerSnapshot = { + ...olderSnapshot, + episode: { + title: 'Newer episode', + seasonNumber: 2, + episodeNumber: 8, + }, + }; + const olderDetail = seriesDetail(olderSnapshot); + const newerDetail = seriesDetail(newerSnapshot, { + id: 18, + xtreamId: 502, + episodeNumber: 8, + title: 'Series - S02E08 - Newer episode', + }); + + const olderResolve = service.resolve(olderDetail); + const newerResolve = service.resolve(newerDetail); + newerProvider.resolve( + providerContent('Newer series title', 93, 'series') + ); + const newerResult = await newerResolve; + olderProvider.resolve( + providerContent('Older series title', 93, 'series') + ); + const olderResult = await olderResolve; + + expect(newerResult.title).toBe('Newer series title'); + expect(olderResult.title).toBe('Older series title'); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + expect(downloads.updateMetadata).toHaveBeenCalledWith(18, newerResult); + }); + + it('returns the best local metadata when provider lookup fails', async () => { + playlists.getPlaylistById.mockReturnValue( + throwError(() => new Error('playlist unavailable')) + ); + const local = snapshot({ plot: undefined }); + + await expect(service.resolve(movieDetail({}, local))).resolves.toEqual( + local + ); + expect(downloads.updateMetadata).not.toHaveBeenCalled(); + }); + + it('returns changed metadata when best-effort persistence fails', async () => { + downloads.updateMetadata.mockRejectedValue( + new Error('persistence unavailable') + ); + db.getContentByXtreamId.mockResolvedValue({ + id: 1, + category_id: 12, + title: 'Recovered provider title', + rating: '7', + added: '0', + poster_url: 'https://images.example.test/provider.jpg', + xtream_id: 41, + type: 'movie', + }); + + await expect(service.resolve(movieDetail())).resolves.toEqual( + expect.objectContaining({ title: 'Recovered provider title' }) + ); + expect(downloads.updateMetadata).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts new file mode 100644 index 000000000..ff02be05d --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata.service.ts @@ -0,0 +1,302 @@ +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + DatabaseService, + DownloadsService, + PlaylistsService, + SettingsStore, + TmdbEnrichmentService, + type DownloadItem, +} from '@iptvnator/services'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, +} from '@iptvnator/portal/shared/util'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { + mapProviderToDownloadSnapshot, + mergeSnapshotWithTmdb, + type DownloadMetadataProviderSource, +} from './download-metadata.mapper'; +import { + currentMetadataLanguage, + finalizeMetadataRefresh, + MetadataRefreshCoordinator, + needsMetadataRefresh, + TMDB_REFRESH_STATE, + type TmdbRefreshResult, + withoutMetadataFreshness, +} from './download-metadata-refresh'; +import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel'; +import { findMatchingStalkerDownloadRecent } from '../stalker-download-recent.matcher'; + +interface ProviderContext { + source: DownloadMetadataProviderSource; + provider?: unknown; +} + +function representative(detail: DownloadOfflineDetail): DownloadItem { + return ( + detail.kind === 'movie' ? detail.item : detail.representative + ) as DownloadItem; +} + +function targetId(detail: DownloadOfflineDetail): number { + const item = representative(detail); + return detail.kind === 'series' + ? (item.seriesXtreamId ?? item.xtreamId) + : item.xtreamId; +} + +function metadataWriteKey(detail: DownloadOfflineDetail): string { + const item = representative(detail); + if ( + detail.kind === 'series' && + Number.isSafeInteger(item.seriesXtreamId) && + (item.seriesXtreamId ?? 0) > 0 + ) { + return `series:${item.playlistId}:${item.seriesXtreamId}`; + } + return `download:${item.id}`; +} + +function episodeTitle(item: DownloadItem): string { + const parts = item.title.split(/\s+-\s+S\d+E\d+\s+-\s+/i); + return parts[parts.length - 1] ?? item.title; +} + +function seriesTitle(item: DownloadItem): string { + return item.title.replace(/\s+-\s+S\d+E\d+(?:\s+-\s+.*)?$/i, '').trim(); +} + +function fallbackSnapshot( + detail: DownloadOfflineDetail, + language: string +): DownloadMetadataSnapshot { + const item = representative(detail); + if (detail.kind === 'movie') { + return withoutMetadataFreshness( + createMovieDownloadSnapshot({ + language, + title: item.title, + posterUrl: item.posterUrl, + }) + ); + } + + const parent = { + language, + title: seriesTitle(item) || item.title, + posterUrl: item.posterUrl, + }; + const seasonNumber = item.seasonNumber; + const episodeNumber = item.episodeNumber; + if ( + !Number.isSafeInteger(seasonNumber) || + (seasonNumber ?? -1) < 0 || + !Number.isSafeInteger(episodeNumber) || + (episodeNumber ?? -1) < 0 + ) { + return withoutMetadataFreshness({ + ...createMovieDownloadSnapshot(parent), + mediaKind: 'series', + }); + } + + return withoutMetadataFreshness( + createSeriesEpisodeDownloadSnapshot({ + ...parent, + episode: { + title: episodeTitle(item), + seasonNumber: seasonNumber as number, + episodeNumber: episodeNumber as number, + }, + }) + ); +} + +function existingSnapshot( + detail: DownloadOfflineDetail +): DownloadMetadataSnapshot | undefined { + const snapshot = detail.snapshot as DownloadMetadataSnapshot | undefined; + const expectedKind = detail.kind === 'movie' ? 'movie' : 'series'; + return snapshot?.version === 1 && snapshot.mediaKind === expectedKind + ? snapshot + : undefined; +} + +@Injectable({ providedIn: 'root' }) +export class DownloadOfflineMetadataService { + private readonly db = inject(DatabaseService); + private readonly downloads = inject(DownloadsService); + private readonly playlists = inject(PlaylistsService); + private readonly settings = inject(SettingsStore); + private readonly tmdb = inject(TmdbEnrichmentService); + private readonly refreshes = new MetadataRefreshCoordinator(); + + async resolve( + detail: DownloadOfflineDetail + ): Promise { + const writeKey = metadataWriteKey(detail); + const generation = this.refreshes.begin(writeKey); + try { + return await this.resolveGeneration(detail, writeKey, generation); + } finally { + this.refreshes.finish(writeKey, generation); + } + } + + private async resolveGeneration( + detail: DownloadOfflineDetail, + writeKey: string, + generation: number + ): Promise { + const language = currentMetadataLanguage(this.settings); + const persisted = existingSnapshot(detail); + const local = persisted ?? fallbackSnapshot(detail, language); + const completedSparseAttempt = this.refreshes.isCompleted( + writeKey, + language + ); + if ( + !needsMetadataRefresh(persisted, language, completedSparseAttempt) + ) { + return local; + } + + const context = await this.loadProviderContext(detail); + let resolved = local; + let providerSucceeded = false; + if (context.provider) { + try { + resolved = mapProviderToDownloadSnapshot({ + source: context.source, + language, + mediaKind: local.mediaKind, + fallback: local, + provider: context.provider, + }); + providerSucceeded = true; + } catch { + resolved = local; + } + } + const tmdb = await this.enrichWithTmdb( + resolved, + context.source, + language + ); + const finalized = finalizeMetadataRefresh({ + language, + local, + providerSucceeded, + snapshot: tmdb.snapshot, + tmdbState: tmdb.state, + }); + if (!finalized.shouldPersist) { + return finalized.snapshot; + } + + await this.refreshes.persistLatest({ + completedAttempt: finalized.completedAttempt, + generation, + groupKey: writeKey, + language, + persist: () => + this.downloads.updateMetadata( + representative(detail).id, + finalized.snapshot + ), + }); + return finalized.snapshot; + } + + private async loadProviderContext( + detail: DownloadOfflineDetail + ): Promise { + const item = representative(detail); + let source: DownloadMetadataProviderSource; + try { + const playlist = await firstValueFrom( + this.playlists.getPlaylistById(item.playlistId) + ); + source = + playlist?.portalUrl && playlist.macAddress + ? 'stalker' + : 'xtream'; + } catch { + return { source: 'xtream' }; + } + + if (source === 'stalker') { + try { + const recent = await firstValueFrom( + this.playlists.getPortalRecentlyViewed(item.playlistId) + ); + return { + source, + provider: findMatchingStalkerDownloadRecent( + recent, + targetId(detail), + item.contentType + ), + }; + } catch { + return { source }; + } + } + + try { + return { + source, + provider: await this.db.getContentByXtreamId( + targetId(detail), + item.playlistId, + detail.kind === 'movie' ? 'movie' : 'series' + ), + }; + } catch { + return { source: 'xtream' }; + } + } + + private async enrichWithTmdb( + snapshot: DownloadMetadataSnapshot, + source: DownloadMetadataProviderSource, + language: string + ): Promise { + try { + if (!this.tmdb.isEnabled()) { + return { + snapshot, + state: TMDB_REFRESH_STATE.DISABLED, + }; + } + const query = { + title: snapshot.title, + originalTitle: snapshot.originalTitle, + year: snapshot.year, + tmdbId: snapshot.tmdbId, + }; + const details = + snapshot.mediaKind === 'movie' + ? await this.tmdb.enrichMovie(query) + : await this.tmdb.enrichTv(query); + if (!details) { + return { snapshot, state: TMDB_REFRESH_STATE.FAILED }; + } + return { + snapshot: mergeSnapshotWithTmdb( + snapshot.language === language + ? snapshot + : { ...snapshot, language }, + details, + source + ), + state: TMDB_REFRESH_STATE.SUCCEEDED, + }; + } catch { + return { snapshot, state: TMDB_REFRESH_STATE.FAILED }; + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts new file mode 100644 index 000000000..0419c0a97 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts @@ -0,0 +1,132 @@ +import { effect, inject, Injectable, Injector, signal } from '@angular/core'; +import type { DownloadItem } from '@iptvnator/services'; +import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util'; +import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; +import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service'; +import type { OfflineDetailItem } from './download-offline-detail.presentation'; + +type ProviderTargetStatus = 'loading' | 'available' | 'unavailable' | 'opening'; + +interface ProviderTargetState { + readonly itemId?: number; + readonly routeGeneration?: number; + readonly status: ProviderTargetStatus; + readonly target?: WorkspaceNavigationTarget; +} + +export type ProviderOpenResult = 'success' | 'failed' | 'ignored'; + +function targetKey( + route: OfflineDetailRouteContext, + item: OfflineDetailItem | undefined +): string | undefined { + if (!item || route.downloadId === undefined) return undefined; + return JSON.stringify([ + route.generation, + route.downloadId, + item.id, + item.playlistId, + item.contentType, + item.xtreamId, + item.seriesXtreamId, + item.title, + item.posterUrl, + item.metadataSnapshot?.providerCategoryId, + ]); +} + +@Injectable() +export class DownloadOfflineProviderCoordinatorService { + private readonly navigation = inject(DownloadLibraryNavigationService); + private readonly injector = inject(Injector); + private readonly targetState = signal({ + status: 'loading', + }); + private generation = 0; + private currentKey?: string; + + readonly state = this.targetState.asReadonly(); + + connect( + route: () => OfflineDetailRouteContext, + item: () => OfflineDetailItem | undefined + ): void { + effect( + () => { + const currentRoute = route(); + const currentItem = item(); + const key = targetKey(currentRoute, currentItem); + if (key === this.currentKey) return; + this.currentKey = key; + const generation = ++this.generation; + if (!currentItem || !key) { + this.targetState.set({ status: 'loading' }); + return; + } + const base = { + itemId: currentItem.id, + routeGeneration: currentRoute.generation, + }; + this.targetState.set({ ...base, status: 'loading' }); + void this.navigation + .resolveProviderTarget(currentItem as DownloadItem) + .then((target) => { + if ( + generation !== this.generation || + key !== this.currentKey + ) { + return; + } + this.targetState.set( + target + ? { ...base, status: 'available', target } + : { ...base, status: 'unavailable' } + ); + }) + .catch(() => { + if ( + generation === this.generation && + key === this.currentKey + ) { + this.targetState.set({ + ...base, + status: 'unavailable', + }); + } + }); + }, + { injector: this.injector } + ); + } + + async open( + route: OfflineDetailRouteContext, + item: OfflineDetailItem | undefined + ): Promise { + const state = this.targetState(); + const key = targetKey(route, item); + if ( + !key || + key !== this.currentKey || + state.status !== 'available' || + !state.target + ) { + return 'ignored'; + } + const opening = { ...state, status: 'opening' as const }; + this.targetState.set(opening); + let succeeded = false; + try { + succeeded = await this.navigation.navigateResolvedTarget( + state.target + ); + } catch { + succeeded = false; + } + if (this.targetState() !== opening || key !== this.currentKey) { + return 'ignored'; + } + this.targetState.set(state); + return succeeded ? 'success' : 'failed'; + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts new file mode 100644 index 000000000..9a34db8ea --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts @@ -0,0 +1,32 @@ +import type { Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import type { ActivatedRoute } from '@angular/router'; +import { distinctUntilChanged, map, scan } from 'rxjs'; +import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service'; +import { parseOfflineDownloadId } from './download-offline-detail.presentation'; + +export function createDownloadOfflineRouteContext( + route: ActivatedRoute +): Signal { + return toSignal( + route.paramMap.pipe( + map((params) => parseOfflineDownloadId(params.get('downloadId'))), + distinctUntilChanged(), + scan( + (previous, downloadId) => ({ + downloadId, + generation: previous.generation + 1, + }), + { generation: 0 } + ) + ), + { + initialValue: { + downloadId: parseOfflineDownloadId( + route.snapshot.paramMap.get('downloadId') + ), + generation: 0, + }, + } + ); +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts new file mode 100644 index 000000000..59b056010 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts @@ -0,0 +1,44 @@ +import { Location } from '@angular/common'; +import { inject, Injectable } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { safeOfflineManagerReturnUrl } from './download-offline-detail.presentation'; + +@Injectable() +export class DownloadOfflineRouteNavigationService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly location = inject(Location); + + back(): void { + if (this.returnUrl()) { + this.location.back(); + return; + } + void this.toManager(false); + } + + async toManager(replaceUrl: boolean): Promise { + try { + const returnUrl = this.returnUrl(); + if (returnUrl) { + return await this.router.navigateByUrl(returnUrl, { + replaceUrl, + }); + } + return await this.router.navigate(['..'], { + relativeTo: this.route, + queryParamsHandling: 'preserve', + ...(replaceUrl ? { replaceUrl: true } : {}), + }); + } catch { + return false; + } + } + + private returnUrl(): string | undefined { + return safeOfflineManagerReturnUrl( + this.location.getState(), + this.router.url + ); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts new file mode 100644 index 000000000..7d548bd21 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts @@ -0,0 +1,128 @@ +import { DOCUMENT } from '@angular/common'; +import { effect, inject, Injectable, Injector, signal } from '@angular/core'; +import type { DownloadOfflineSeason } from './download-offline-detail.viewmodel'; +import { offlineSeasonKey } from './download-offline-detail.presentation'; +import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service'; + +interface SeasonSelection { + readonly key: string; + readonly routeGeneration: number; +} + +@Injectable() +export class DownloadOfflineSeasonSelectionService { + private readonly document = inject(DOCUMENT); + private readonly injector = inject(Injector); + private readonly selection = signal(undefined); + + connect( + route: () => OfflineDetailRouteContext, + seasons: () => readonly DownloadOfflineSeason[] + ): void { + effect( + () => { + const currentRoute = route(); + const available = seasons(); + const current = this.selection(); + const currentAvailable = available.some( + (season) => offlineSeasonKey(season) === current?.key + ); + if (available.length === 0) { + if (current) this.selection.set(undefined); + return; + } + if ( + current?.routeGeneration === currentRoute.generation && + currentAvailable + ) { + return; + } + const shouldMoveFocus = + current?.routeGeneration === currentRoute.generation && + this.document.activeElement?.id === + this.tabIdForKey(current.key); + const normalized = { + key: offlineSeasonKey(available[0]), + routeGeneration: currentRoute.generation, + }; + this.selection.set(normalized); + if (shouldMoveFocus) this.focusAfterRender(normalized); + }, + { injector: this.injector } + ); + } + + selected( + route: OfflineDetailRouteContext, + seasons: readonly DownloadOfflineSeason[] + ): DownloadOfflineSeason | undefined { + const current = this.selection(); + if (current?.routeGeneration !== route.generation) return seasons[0]; + return ( + seasons.find( + (season) => offlineSeasonKey(season) === current.key + ) ?? seasons[0] + ); + } + + select( + route: OfflineDetailRouteContext, + season: DownloadOfflineSeason + ): void { + this.selection.set({ + key: offlineSeasonKey(season), + routeGeneration: route.generation, + }); + } + + tabId(season: DownloadOfflineSeason): string { + return this.tabIdForKey(offlineSeasonKey(season)); + } + + handleKeydown( + event: KeyboardEvent, + index: number, + route: OfflineDetailRouteContext, + seasons: readonly DownloadOfflineSeason[] + ): void { + if (seasons.length === 0) return; + const last = seasons.length - 1; + const target = this.keyboardTarget(event.key, index, last); + if (target === undefined) return; + event.preventDefault(); + this.select(route, seasons[target]); + const tabs = ( + event.currentTarget as HTMLElement | null + )?.parentElement?.querySelectorAll('[role="tab"]'); + tabs?.[target]?.focus(); + } + + private keyboardTarget( + key: string, + index: number, + last: number + ): number | undefined { + if (key === 'Home') return 0; + if (key === 'End') return last; + if (key === 'ArrowRight' || key === 'ArrowDown') { + return index === last ? 0 : index + 1; + } + if (key === 'ArrowLeft' || key === 'ArrowUp') { + return index === 0 ? last : index - 1; + } + return undefined; + } + + private focusAfterRender(selection: SeasonSelection): void { + queueMicrotask(() => { + if (this.selection() !== selection) return; + this.document + .getElementById(this.tabIdForKey(selection.key)) + ?.focus(); + }); + } + + private tabIdForKey(key: string): string { + return `offline-${key}-tab`; + } +} diff --git a/libs/portal/downloads/feature/src/lib/stalker-download-recent.matcher.ts b/libs/portal/downloads/feature/src/lib/stalker-download-recent.matcher.ts new file mode 100644 index 000000000..70182dde3 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/stalker-download-recent.matcher.ts @@ -0,0 +1,71 @@ +import { + extractStalkerItemType, + type StalkerPortalItem, +} from '@iptvnator/shared/interfaces'; + +type DownloadContentType = 'vod' | 'episode'; + +function normalizedPortalId(value: unknown): string { + return String(value ?? '') + .trim() + .split(':')[0]; +} + +function hasValue(value: unknown): boolean { + return String(value ?? '').trim().length > 0; +} + +function isTruthySeriesFlag(value: unknown): boolean { + return value === true || value === 1 || value === '1'; +} + +function isSeriesItem(item: StalkerPortalItem): boolean { + const raw = item as Record; + const category = String(item.category_id ?? '') + .trim() + .toLowerCase(); + const streamType = String(item.stream_type ?? '') + .trim() + .toLowerCase(); + const customSid = String(raw['custom_sid'] ?? '') + .trim() + .toLowerCase(); + + return ( + hasValue(item.series_id) || + category === 'series' || + streamType === 'series' || + isTruthySeriesFlag(item.is_series) || + (Array.isArray(item.series) && item.series.length > 0) || + customSid === 'regular-series' || + customSid === 'vod-series' + ); +} + +function matchesContentType( + item: StalkerPortalItem, + contentType: DownloadContentType +): boolean { + if (extractStalkerItemType(item) === 'live') { + return false; + } + + const seriesItem = isSeriesItem(item); + return contentType === 'episode' ? seriesItem : !seriesItem; +} + +export function findMatchingStalkerDownloadRecent( + items: readonly StalkerPortalItem[], + expectedId: number, + contentType: DownloadContentType +): StalkerPortalItem | undefined { + const expected = String(expectedId); + + return items.find( + (item) => + matchesContentType(item, contentType) && + [item.id, item.movie_id, item.series_id, item.stream_id].some( + (candidate) => normalizedPortalId(candidate) === expected + ) + ); +} diff --git a/libs/portal/downloads/feature/src/test-setup.ts b/libs/portal/downloads/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/downloads/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/downloads/feature/tsconfig.spec.json b/libs/portal/downloads/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/downloads/feature/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index f661cf4d9..b7490f4cf 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -23,8 +23,10 @@ export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; export * from './lib/workspace-view-command.service'; export * from './lib/navigation/portal-rail-links'; +export * from './lib/navigation/provider-detail-mode'; export * from './lib/navigation/portal-route.utils'; export * from './lib/navigation/workspace-portal-navigation'; export * from './lib/collection'; export * from './lib/external-playback-button-state'; export * from './lib/inline-playback-position-writer'; +export * from './lib/downloads/download-metadata-snapshot'; diff --git a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts new file mode 100644 index 000000000..fcbf2cf59 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.spec.ts @@ -0,0 +1,205 @@ +import type { DownloadMetadataPerson } from '@iptvnator/shared/interfaces'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, +} from './download-metadata-snapshot'; + +function people(count: number): DownloadMetadataPerson[] { + return Array.from({ length: count }, (_, index) => ({ + tmdbPersonId: index + 1, + name: `Person ${index + 1}`, + role: `Role ${index + 1}`, + profileUrl: `https://image.tmdb.org/t/p/w185/person-${index + 1}.jpg`, + })); +} + +describe('download metadata snapshot factories', () => { + it('creates an allowlisted movie snapshot without provider secrets or stream URLs', () => { + const snapshot = createMovieDownloadSnapshot({ + language: 'de', + title: 'Provider title', + originalTitle: 'Original title', + plot: 'Provider plot', + posterUrl: 'https://images.example.test/poster.jpg', + providerCategoryId: '12', + enrichedAt: '2026-07-31T08:30:00.000Z', + username: 'account-user', + password: 'account-secret', + url: 'https://stream.example.test/movie/41.m3u8', + portalUrl: 'https://portal.example.test', + macAddress: '00:1A:79:12:34:56', + } as Parameters[0] & + Record); + + expect(snapshot).toEqual({ + version: 1, + language: 'de', + mediaKind: 'movie', + title: 'Provider title', + originalTitle: 'Original title', + plot: 'Provider plot', + posterUrl: 'https://images.example.test/poster.jpg', + providerCategoryId: '12', + enrichedAt: '2026-07-31T08:30:00.000Z', + }); + expect(JSON.stringify(snapshot)).not.toContain('account-secret'); + expect(JSON.stringify(snapshot)).not.toContain('.m3u8'); + expect(snapshot).not.toHaveProperty('username'); + expect(snapshot).not.toHaveProperty('portalUrl'); + }); + + it('creates series metadata with parent and episode display fields', () => { + const snapshot = createSeriesEpisodeDownloadSnapshot({ + language: 'en', + title: 'Signal House', + plot: 'Parent series plot', + posterUrl: 'https://images.example.test/series.jpg', + providerCategoryId: '8', + episode: { + title: 'The Relay', + plot: 'Episode plot', + stillUrl: 'https://images.example.test/relay.jpg', + seasonNumber: 2, + episodeNumber: 4, + }, + enrichedAt: '2026-07-31T08:30:00.000Z', + }); + + expect(snapshot).toEqual({ + version: 1, + language: 'en', + mediaKind: 'series', + title: 'Signal House', + plot: 'Parent series plot', + posterUrl: 'https://images.example.test/series.jpg', + providerCategoryId: '8', + episode: { + title: 'The Relay', + plot: 'Episode plot', + stillUrl: 'https://images.example.test/relay.jpg', + seasonNumber: 2, + episodeNumber: 4, + }, + enrichedAt: '2026-07-31T08:30:00.000Z', + }); + }); + + it('bounds people to 30 and genres to 20 in deterministic input order', () => { + const snapshot = createMovieDownloadSnapshot({ + language: 'en', + title: 'Bounded metadata', + genres: Array.from( + { length: 25 }, + (_, index) => `Genre ${index + 1}` + ), + cast: people(35), + creators: people(34), + }); + + expect(snapshot.genres).toHaveLength(20); + expect(snapshot.genres?.[19]).toBe('Genre 20'); + expect(snapshot.cast).toHaveLength(30); + expect(snapshot.cast?.[29]?.name).toBe('Person 30'); + expect(snapshot.creators).toHaveLength(30); + expect(snapshot.creators?.[29]?.name).toBe('Person 30'); + expect(snapshot.enrichedAt).toEqual(expect.any(String)); + }); + + it('keeps safe artwork names while dropping stream and credential URLs', () => { + const snapshot = createMovieDownloadSnapshot({ + language: 'en', + title: 'Safe artwork', + posterUrl: 'https://streams.example.test/live/123', + backdropUrl: + 'https://viewer:secret@images.example.test/backdrop.jpg', + cast: [ + { + name: 'Actor', + profileUrl: + 'https://images.example.test/posters/secret-garden.jpg', + }, + ], + }); + + expect(snapshot.posterUrl).toBeUndefined(); + expect(snapshot.backdropUrl).toBeUndefined(); + expect(snapshot.cast?.[0]?.profileUrl).toBe( + 'https://images.example.test/posters/secret-garden.jpg' + ); + }); + + it.each([ + 'accesskey', + 'accesstoken', + 'apikey', + 'auth', + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'credentials', + 'devicemac', + 'key', + 'mac', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'privatekey', + 'refreshtoken', + 'secret', + 'secretkey', + 'session', + 'sig', + 'signature', + 'signingkey', + 'token', + 'username', + ])('drops artwork containing backend credential path alias %s', (alias) => { + const snapshot = createMovieDownloadSnapshot({ + language: 'en', + title: 'Unsafe artwork', + posterUrl: `https://images.example.test/${alias}/value/poster.jpg`, + }); + + expect(snapshot.posterUrl).toBeUndefined(); + }); + + it.each([ + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'secret', + 'session', + 'signature', + 'token', + 'auth', + 'key', + 'mac', + 'sig', + 'proxy-auth', + 'device-mac', + 'access-key', + 'api-key', + 'private-key', + 'secret-key', + 'signing-key', + 'username', + ])( + 'drops artwork containing backend credential query alias %s', + (alias) => { + const snapshot = createMovieDownloadSnapshot({ + language: 'en', + title: 'Unsafe artwork', + posterUrl: `https://images.example.test/poster.jpg?${alias}=value`, + }); + + expect(snapshot.posterUrl).toBeUndefined(); + } + ); +}); diff --git a/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts new file mode 100644 index 000000000..1a6f53b2e --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/download-metadata-snapshot.ts @@ -0,0 +1,319 @@ +import type { + DownloadEpisodeMetadata, + DownloadMetadataPerson, + DownloadMetadataSnapshot, +} from '@iptvnator/shared/interfaces'; + +const MAX_PEOPLE = 30; +const MAX_GENRES = 20; +const STREAM_EXTENSION = /\.(?:m3u8?|mpd|mp4|mkv|mov|ts|webm)(?:$|[?#])/i; +const IMAGE_EXTENSION = /\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i; +const TRUSTED_IMAGE_HOSTS = new Set(['image.tmdb.org', 'picsum.photos']); +const IMAGE_PATH_HINTS = new Set([ + 'backdrop', + 'backdrops', + 'cover', + 'covers', + 'getimage', + 'image', + 'images', + 'logo', + 'logos', + 'poster', + 'posters', + 'still', + 'stills', +]); +// Keep these aliases synchronized with the canonical Electron validator in +// apps/electron-backend/src/app/events/database/download-artwork-url.ts. +const CREDENTIAL_PATH_KEYS = new Set([ + 'accesskey', + 'accesstoken', + 'apikey', + 'auth', + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'credentials', + 'devicemac', + 'key', + 'mac', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'privatekey', + 'refreshtoken', + 'secret', + 'secretkey', + 'session', + 'sig', + 'signature', + 'signingkey', + 'token', + 'username', +]); +const CREDENTIAL_QUERY_TERMS = [ + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'secret', + 'session', + 'signature', + 'token', + 'username', +]; + +interface DownloadSnapshotFields { + language: string; + title: string; + originalTitle?: string; + plot?: string; + releaseDate?: string; + year?: number; + durationMinutes?: number; + genres?: readonly string[]; + rating?: number; + status?: string; + posterUrl?: string; + backdropUrl?: string; + tmdbId?: number; + providerCategoryId?: string; + cast?: readonly DownloadMetadataPerson[]; + creators?: readonly DownloadMetadataPerson[]; + enrichedAt?: string; +} + +export type DownloadMovieSnapshotInput = DownloadSnapshotFields; + +export interface DownloadSeriesEpisodeSnapshotInput extends DownloadSnapshotFields { + episode: DownloadEpisodeMetadata; +} + +function text(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function finite(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) ? value : undefined; +} + +function normalizedToken(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function isCredentialQueryKey(key: string): boolean { + const token = normalizedToken(key); + return ( + ['auth', 'key', 'mac', 'sig'].includes(token) || + token.endsWith('auth') || + token.endsWith('mac') || + CREDENTIAL_QUERY_TERMS.some((term) => token.includes(term)) || + ['accesskey', 'apikey', 'privatekey', 'secretkey', 'signingkey'].some( + (term) => token.includes(term) + ) + ); +} + +function credentialPath(pathname: string): boolean { + return pathname + .split('/') + .filter(Boolean) + .some((segment) => { + const separator = segment.search(/[=:]/); + const key = separator < 0 ? segment : segment.slice(0, separator); + return CREDENTIAL_PATH_KEYS.has(normalizedToken(key)); + }); +} + +function positiveImageSignal(url: URL, pathname: string): boolean { + let imageAction = false; + url.searchParams.forEach((value, key) => { + imageAction ||= + ['action', 'method'].includes(normalizedToken(key)) && + ['getimage', 'image'].includes(normalizedToken(value)); + }); + return ( + IMAGE_EXTENSION.test(pathname) || + TRUSTED_IMAGE_HOSTS.has(url.hostname) || + pathname + .split('/') + .filter(Boolean) + .some((segment) => + IMAGE_PATH_HINTS.has(normalizedToken(segment)) + ) || + imageAction + ); +} + +function integer(value: number | undefined): number | undefined { + return value !== undefined && Number.isSafeInteger(value) + ? value + : undefined; +} + +function artworkUrl(value: string | undefined): string | undefined { + const normalized = text(value); + if (!normalized || STREAM_EXTENSION.test(normalized)) { + return undefined; + } + + try { + const url = new URL(normalized); + const pathname = decodeURIComponent(url.pathname).replace(/\/+$/, ''); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.hash || + credentialPath(pathname) || + !positiveImageSignal(url, pathname) + ) { + return undefined; + } + let hasCredentialQuery = false; + url.searchParams.forEach((_value, key) => { + if (isCredentialQueryKey(key)) { + hasCredentialQuery = true; + } + }); + if (hasCredentialQuery) { + return undefined; + } + return normalized; + } catch { + return undefined; + } +} + +function stringList( + values: readonly string[] | undefined, + limit: number +): string[] | undefined { + if (!values) { + return undefined; + } + const normalized: string[] = []; + const seen = new Set(); + for (const value of values) { + const entry = text(value); + if (!entry || seen.has(entry)) { + continue; + } + seen.add(entry); + normalized.push(entry); + if (normalized.length === limit) { + break; + } + } + return normalized.length > 0 ? normalized : undefined; +} + +function person(value: DownloadMetadataPerson): DownloadMetadataPerson | null { + const name = text(value.name); + if (!name) { + return null; + } + const tmdbPersonId = integer(value.tmdbPersonId); + const role = text(value.role); + const profileUrl = artworkUrl(value.profileUrl); + return { + ...(tmdbPersonId === undefined ? {} : { tmdbPersonId }), + name, + ...(role === undefined ? {} : { role }), + ...(profileUrl === undefined ? {} : { profileUrl }), + }; +} + +function people( + values: readonly DownloadMetadataPerson[] | undefined +): DownloadMetadataPerson[] | undefined { + if (!values) { + return undefined; + } + const normalized: DownloadMetadataPerson[] = []; + for (const value of values) { + const entry = person(value); + if (entry) { + normalized.push(entry); + } + if (normalized.length === MAX_PEOPLE) { + break; + } + } + return normalized.length > 0 ? normalized : undefined; +} + +function episode(value: DownloadEpisodeMetadata): DownloadEpisodeMetadata { + const title = text(value.title); + const plot = text(value.plot); + const stillUrl = artworkUrl(value.stillUrl); + return { + ...(title === undefined ? {} : { title }), + ...(plot === undefined ? {} : { plot }), + ...(stillUrl === undefined ? {} : { stillUrl }), + seasonNumber: value.seasonNumber, + episodeNumber: value.episodeNumber, + }; +} + +function snapshotFields(input: DownloadSnapshotFields) { + const originalTitle = text(input.originalTitle); + const plot = text(input.plot); + const releaseDate = text(input.releaseDate); + const year = integer(input.year); + const durationMinutes = integer(input.durationMinutes); + const genres = stringList(input.genres, MAX_GENRES); + const rating = finite(input.rating); + const status = text(input.status); + const posterUrl = artworkUrl(input.posterUrl); + const backdropUrl = artworkUrl(input.backdropUrl); + const tmdbId = integer(input.tmdbId); + const providerCategoryId = text(input.providerCategoryId); + const cast = people(input.cast); + const creators = people(input.creators); + + return { + version: 1 as const, + language: input.language.trim(), + title: input.title.trim(), + ...(originalTitle === undefined ? {} : { originalTitle }), + ...(plot === undefined ? {} : { plot }), + ...(releaseDate === undefined ? {} : { releaseDate }), + ...(year === undefined ? {} : { year }), + ...(durationMinutes === undefined ? {} : { durationMinutes }), + ...(genres === undefined ? {} : { genres }), + ...(rating === undefined ? {} : { rating }), + ...(status === undefined ? {} : { status }), + ...(posterUrl === undefined ? {} : { posterUrl }), + ...(backdropUrl === undefined ? {} : { backdropUrl }), + ...(tmdbId === undefined ? {} : { tmdbId }), + ...(providerCategoryId === undefined ? {} : { providerCategoryId }), + ...(cast === undefined ? {} : { cast }), + ...(creators === undefined ? {} : { creators }), + enrichedAt: text(input.enrichedAt) ?? new Date().toISOString(), + }; +} + +export function createMovieDownloadSnapshot( + input: DownloadMovieSnapshotInput +): DownloadMetadataSnapshot { + return { ...snapshotFields(input), mediaKind: 'movie' }; +} + +export function createSeriesEpisodeDownloadSnapshot( + input: DownloadSeriesEpisodeSnapshotInput +): DownloadMetadataSnapshot { + return { + ...snapshotFields(input), + mediaKind: 'series', + episode: episode(input.episode), + }; +} diff --git a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts new file mode 100644 index 000000000..14628ee62 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.spec.ts @@ -0,0 +1,20 @@ +import { + PROVIDER_ONLY_DETAIL_PRESENTATION, + isProviderOnlyDetailState, +} from './provider-detail-mode'; + +describe('provider detail presentation state', () => { + it('recognizes only the explicit provider-only marker', () => { + expect( + isProviderOnlyDetailState({ + detailPresentation: PROVIDER_ONLY_DETAIL_PRESENTATION, + }) + ).toBe(true); + + expect(isProviderOnlyDetailState(null)).toBe(false); + expect(isProviderOnlyDetailState({})).toBe(false); + expect( + isProviderOnlyDetailState({ detailPresentation: 'offline' }) + ).toBe(false); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts new file mode 100644 index 000000000..2e0e6b116 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts @@ -0,0 +1,13 @@ +export const PROVIDER_ONLY_DETAIL_PRESENTATION = 'provider-only' as const; +export const PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY = + 'detailPresentation' as const; + +export function isProviderOnlyDetailState(state: unknown): boolean { + return ( + typeof state === 'object' && + state !== null && + (state as Record)[ + PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY + ] === PROVIDER_ONLY_DETAIL_PRESENTATION + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index 24ca1636b..74eeca1f1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -1,8 +1,11 @@ @if (contentType() === 'vod') { @if ((selectedItem()?.series?.length ?? 0) > 0) { - + } @else if (isSeriesDetail()) { - + } @else if (vodDetailsItem()) { } } @else if (contentType() === 'series') { - + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts new file mode 100644 index 000000000..ca8342dab --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -0,0 +1,181 @@ +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +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 { + DownloadsService, + PlaybackPositionRuntimeBridgeService, + PlaylistsService, +} from '@iptvnator/services'; +import { VodDetailsComponent } from '@iptvnator/ui/playback'; +import { EMPTY, of } from 'rxjs'; +import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; +import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; +import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component'; + +@Component({ selector: 'app-vod-details', template: '' }) +class StubVodDetailsComponent { + readonly item = input.required(); + readonly providerOnly = input(false); + readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); + readonly playClicked = output(); + readonly resumeClicked = output(); + readonly favoriteToggled = output(); + readonly downloadRequested = output(); + readonly backClicked = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); +} + +@Component({ selector: 'app-stalker-series-view', template: '' }) +class StubStalkerSeriesViewComponent { + readonly vodWithSeries = input(null); + readonly providerOnly = input(false); +} + +describe('StalkerCatalogDetailComponent provider presentation', () => { + let fixture: ComponentFixture; + const contentType = signal<'vod' | 'series'>('vod'); + const selectedItem = signal({ + id: '42', + cmd: '/media/42', + info: { name: 'Portal movie' }, + }); + + beforeEach(async () => { + contentType.set('vod'); + selectedItem.set({ + id: '42', + cmd: '/media/42', + info: { name: 'Portal movie' }, + }); + + await TestBed.configureTestingModule({ + imports: [StalkerCatalogDetailComponent], + providers: [ + { + provide: StalkerCatalogFacadeService, + useValue: { + contentType, + selectedItem, + playlist: signal({ id: 'stalker-1' }), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { isEmbeddedPlayer: jest.fn() }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { onPlaybackPositionUpdate: jest.fn() }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: jest.fn(() => of([])) }, + }, + { provide: DownloadsService, useValue: {} }, + { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerCatalogDetailComponent, { + remove: { + imports: [StalkerSeriesViewComponent, VodDetailsComponent], + }, + add: { + imports: [ + StubStalkerSeriesViewComponent, + StubVodDetailsComponent, + ], + }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerCatalogDetailComponent); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('passes provider-only mode to the matching regular VOD', async () => { + fixture.componentRef.setInput('providerOnly', true); + await fixture.whenStable(); + + const child = fixture.debugElement.query( + By.directive(StubVodDetailsComponent) + ).componentInstance as StubVodDetailsComponent; + expect(fixture.componentInstance.providerOnly()).toBe(true); + expect(child.providerOnly()).toBe(true); + }); + + it('keeps provider-only presentation disabled for a regular VOD open', async () => { + selectedItem.set({ + id: '99', + cmd: '/media/99', + info: { name: 'Another movie' }, + }); + await fixture.whenStable(); + + const child = fixture.debugElement.query( + By.directive(StubVodDetailsComponent) + ).componentInstance as StubVodDetailsComponent; + expect(fixture.componentInstance.providerOnly()).toBe(false); + expect(child.providerOnly()).toBe(false); + }); + + it.each([ + ['embedded VOD series', 'vod', { id: '42', series: [1] }], + ['Ministra VOD series', 'vod', { id: '42', is_series: '1' }], + ['regular series', 'series', { id: '42' }], + ] as const)( + 'passes provider-only mode to %s', + async (_label, type, item) => { + fixture.componentRef.setInput('providerOnly', true); + contentType.set(type); + selectedItem.set({ + ...item, + cmd: '/media/42', + info: { name: 'Portal series' }, + }); + await fixture.whenStable(); + + const child = fixture.debugElement.query( + By.directive(StubStalkerSeriesViewComponent) + ).componentInstance as StubStalkerSeriesViewComponent; + expect(child.providerOnly()).toBe(true); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index a04627bf4..ded5cb3c3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -4,6 +4,7 @@ import { computed, effect, inject, + input, signal, } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -83,6 +84,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { null ); readonly inlinePlayback = signal(null); + readonly providerOnly = input(false); private readonly selectedVodPosition = signal( null ); @@ -262,6 +264,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy { fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id), fetchLinkToPlay: (portalUrl, macAddress, cmd) => this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd), + language: + this.translateService.currentLang || + this.translateService.defaultLang || + 'en', }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts new file mode 100644 index 000000000..0fc8e061d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts @@ -0,0 +1,123 @@ +import type { VodDetailsItem } from '@iptvnator/shared/interfaces'; +import { startStalkerVodDownload } from './stalker-vod-download'; + +describe('startStalkerVodDownload', () => { + it('captures the rendered Stalker movie metadata without changing provider identity', async () => { + const startDownload = jest.fn().mockResolvedValue({ success: true }); + const deps = { + playlist: { + id: 'stalker-1', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + title: 'Living Room Portal', + }, + downloadsService: { startDownload }, + fetchMovieFileId: jest.fn(), + fetchLinkToPlay: jest + .fn() + .mockResolvedValue('https://cdn.example.test/movie.mpg'), + language: 'de', + }; + const item = { + type: 'stalker', + playlistId: 'stalker-1', + cmd: '/media/file_42.mpg', + data: { + id: '42', + cmd: '/media/file_42.mpg', + category_id: '7', + info: { + name: 'Metadata Movie', + o_name: 'Original Metadata Movie', + description: 'A captured Stalker plot', + movie_image: + 'https://images.example.test/posters/stalker-movie.jpg', + tmdb_backdrop: + 'https://images.example.test/backdrops/stalker-movie.jpg', + releasedate: '2024-11-03', + genre: 'Drama, Thriller', + rating_imdb: '7.7', + tmdb_id: 541, + actors: 'Stella Star, Mira Moon', + director: 'Dorian Vale', + tmdb_cast: [], + tmdb_directors: [], + }, + }, + } as unknown as VodDetailsItem; + + await startStalkerVodDownload(item, deps); + + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + playlistId: 'stalker-1', + playlistName: 'Living Room Portal', + playlistType: 'stalker', + portalUrl: 'https://stalker.example.test', + xtreamId: 42, + metadataSnapshot: expect.objectContaining({ + version: 1, + language: 'de', + mediaKind: 'movie', + title: 'Metadata Movie', + originalTitle: 'Original Metadata Movie', + plot: 'A captured Stalker plot', + posterUrl: + 'https://images.example.test/posters/stalker-movie.jpg', + backdropUrl: + 'https://images.example.test/backdrops/stalker-movie.jpg', + providerCategoryId: '7', + genres: ['Drama', 'Thriller'], + tmdbId: 541, + cast: [{ name: 'Stella Star' }, { name: 'Mira Moon' }], + creators: [{ name: 'Dorian Vale' }], + }), + }) + ); + }); + + it('still captures a useful sparse Stalker snapshot', async () => { + const startDownload = jest.fn().mockResolvedValue({ success: true }); + const item = { + type: 'stalker', + playlistId: 'stalker-1', + cmd: '/media/file_9.mpg', + data: { + id: '9', + cmd: '/media/file_9.mpg', + title: 'Sparse title', + info: { + name: ' ', + rating_imdb: ' ', + tmdb_id: ' ', + }, + }, + } as unknown as VodDetailsItem; + + await startStalkerVodDownload(item, { + playlist: { + id: 'stalker-1', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }, + downloadsService: { startDownload }, + fetchMovieFileId: jest.fn(), + fetchLinkToPlay: jest + .fn() + .mockResolvedValue('https://cdn.example.test/movie.mpg'), + }); + + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + title: ' ', + metadataSnapshot: expect.objectContaining({ + mediaKind: 'movie', + title: 'Sparse title', + }), + }) + ); + const snapshot = startDownload.mock.calls[0]?.[0]?.metadataSnapshot; + expect(snapshot).not.toHaveProperty('rating'); + expect(snapshot).not.toHaveProperty('tmdbId'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts index 06ee93e7e..1d571bba0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -1,5 +1,9 @@ import type { DownloadsService } from '@iptvnator/services'; -import type { VodDetailsItem } from '@iptvnator/shared/interfaces'; +import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util'; +import type { + TmdbEnrichedCastMember, + VodDetailsItem, +} from '@iptvnator/shared/interfaces'; /** The Stalker branch of the item union — the only one carrying `cmd`. */ type StalkerVodDetailsItem = Extract; @@ -22,7 +26,22 @@ export interface DownloadVodData { id?: string | number; has_files?: unknown; title?: string; - info?: { name?: string; movie_image?: string }; + category_id?: string | number; + info?: { + name?: string; + o_name?: string; + description?: string; + movie_image?: string; + tmdb_backdrop?: string; + releasedate?: string; + genre?: string; + rating_imdb?: string | number; + tmdb_id?: string | number; + tmdb_cast?: TmdbEnrichedCastMember[]; + tmdb_directors?: TmdbEnrichedCastMember[]; + actors?: string; + director?: string; + }; } export interface StalkerVodDownloadPlaylist { @@ -44,6 +63,46 @@ export interface StalkerVodDownloadDeps { macAddress: string, cmd: string ) => Promise; + language?: string; +} + +function textList(value: string | undefined): string[] | undefined { + const entries = value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries?.length ? entries : undefined; +} + +function number(value: string | number | undefined): number | undefined { + if (value === undefined || String(value).trim() === '') { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function year(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function firstText(...values: (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find(Boolean); +} + +function people( + enriched: TmdbEnrichedCastMember[] | undefined, + fallback: string | undefined +) { + return enriched?.length + ? enriched.map((person) => ({ + name: person.name, + role: person.character, + profileUrl: person.profileUrl ?? undefined, + tmdbPersonId: person.tmdbPersonId, + })) + : textList(fallback)?.map((name) => ({ name })); } export async function startStalkerVodDownload( @@ -60,6 +119,9 @@ export async function startStalkerVodDownload( } const itemData = item.data as DownloadVodData; + const downloadTitle = itemData?.info?.name || itemData?.title || 'Unknown'; + const snapshotTitle = + firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown'; const cmdToUse = await resolveDownloadCmd(item, itemData, deps); const url = await deps.fetchLinkToPlay( @@ -75,9 +137,31 @@ export async function startStalkerVodDownload( playlistId: playlist.id, xtreamId: normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0, contentType: 'vod', - title: itemData?.info?.name || itemData?.title || 'Unknown', + title: downloadTitle, url, posterUrl: itemData?.info?.movie_image, + metadataSnapshot: createMovieDownloadSnapshot({ + language: deps.language?.trim() || 'en', + title: snapshotTitle, + originalTitle: itemData.info?.o_name, + plot: itemData.info?.description, + releaseDate: itemData.info?.releasedate, + year: year(itemData.info?.releasedate), + genres: textList(itemData.info?.genre), + rating: number(itemData.info?.rating_imdb), + posterUrl: itemData.info?.movie_image, + backdropUrl: itemData.info?.tmdb_backdrop, + tmdbId: number(itemData.info?.tmdb_id), + providerCategoryId: + itemData.category_id === undefined + ? undefined + : String(itemData.category_id), + cast: people(itemData.info?.tmdb_cast, itemData.info?.actors), + creators: people( + itemData.info?.tmdb_directors, + itemData.info?.director + ), + }), headers: { userAgent: playlist.userAgent, referer: playlist.referer, diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index c2722c818..52d484ea4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -11,6 +11,11 @@ const loadDownloadsComponent: ComponentLoader = () => (c) => c.DownloadsComponent ); +const loadDownloadOfflineDetailComponent: ComponentLoader = () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadOfflineDetailComponent + ); + const loadStalkerLiveStreamLayoutComponent: ComponentLoader = () => import('./stalker-live-stream-layout/stalker-live-stream-layout.component').then( (c) => c.StalkerLiveStreamLayoutComponent @@ -129,6 +134,10 @@ export function createStalkerRoutes(): Route[] { path: 'actor/:personId', loadComponent: loadStalkerActorRouteComponent, }, + { + path: 'downloads/:downloadId', + loadComponent: loadDownloadOfflineDetailComponent, + }, { path: 'downloads', loadComponent: loadDownloadsComponent, diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index 16cdd2eff..b97e8e102 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -3,11 +3,15 @@ } @else if (isSeries()) { - + } @else if (vodDetailsItem(); as item) { } } @else if (categoryId() === 'series') { - + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts new file mode 100644 index 000000000..57594f7ad --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts @@ -0,0 +1,93 @@ +import { Component, input, output } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { createStalkerVodItem } from '@iptvnator/shared/interfaces'; +import { VodDetailsComponent } from '@iptvnator/ui/playback'; +import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; +import { StalkerInlineDetailComponent } from './stalker-inline-detail.component'; + +@Component({ selector: 'app-vod-details', template: '' }) +class StubVodDetailsComponent { + readonly item = input.required(); + readonly providerOnly = input(false); + readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); + readonly playClicked = output(); + readonly resumeClicked = output(); + readonly favoriteToggled = output(); + readonly backClicked = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); +} + +@Component({ selector: 'app-stalker-series-view', template: '' }) +class StubStalkerSeriesViewComponent { + readonly vodWithSeries = input(null); + readonly providerOnly = input(false); + readonly backClicked = output(); +} + +const VOD_ITEM = createStalkerVodItem( + { id: '42', cmd: '/media/42', info: { name: 'Movie' } }, + 'stalker-1' +); + +describe('StalkerInlineDetailComponent provider presentation', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StalkerInlineDetailComponent], + }) + .overrideComponent(StalkerInlineDetailComponent, { + remove: { + imports: [StalkerSeriesViewComponent, VodDetailsComponent], + }, + add: { + imports: [ + StubStalkerSeriesViewComponent, + StubVodDetailsComponent, + ], + }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerInlineDetailComponent); + fixture.componentRef.setInput('providerOnly', true); + }); + + afterEach(() => fixture.destroy()); + + it('passes provider-only mode to regular VOD details', async () => { + fixture.componentRef.setInput('categoryId', 'vod'); + fixture.componentRef.setInput('vodDetailsItem', VOD_ITEM); + await fixture.whenStable(); + + const child = fixture.debugElement.query( + By.directive(StubVodDetailsComponent) + ).componentInstance as StubVodDetailsComponent; + expect(child.providerOnly()).toBe(true); + }); + + it.each([ + ['embedded VOD series', 'vod', { id: '42', series: [1] }, false], + ['Ministra VOD series', 'vod', { id: '42' }, true], + ['regular series', 'series', { id: '42' }, false], + ] as const)( + 'passes provider-only mode to %s details', + async (_label, categoryId, seriesItem, isSeries) => { + fixture.componentRef.setInput('categoryId', categoryId); + fixture.componentRef.setInput('seriesItem', seriesItem); + fixture.componentRef.setInput('isSeries', isSeries); + await fixture.whenStable(); + + const child = fixture.debugElement.query( + By.directive(StubStalkerSeriesViewComponent) + ).componentInstance as StubStalkerSeriesViewComponent; + expect(child.providerOnly()).toBe(true); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index 38f4fdea1..37cfaf1ef 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -40,6 +40,7 @@ export class StalkerInlineDetailComponent { readonly playbackPosition = input(null); readonly inlinePlayback = input(null); readonly externalPlayback = input(null); + readonly providerOnly = input(false); readonly backClicked = output(); readonly playClicked = output(); @@ -68,10 +69,7 @@ export class StalkerInlineDetailComponent { this.playClicked.emit(item); } - onResumeClicked(event: { - item: VodDetailsItem; - positionSeconds: number; - }) { + onResumeClicked(event: { item: VodDetailsItem; positionSeconds: number }) { this.resumeClicked.emit(event); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts new file mode 100644 index 000000000..d0ffec173 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.spec.ts @@ -0,0 +1,58 @@ +import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata'; + +describe('createStalkerSeriesDownloadSnapshot', () => { + it('preserves zero coordinates from the download row', () => { + const snapshot = createStalkerSeriesDownloadSnapshot({ + item: { + id: '50', + category_id: '18', + info: { + name: 'Signal House', + description: 'Parent plot', + actors: 'Sienna Wave', + director: 'Cora Bell', + }, + } as never, + episode: { + title: 'The Call', + info: { plot: 'Episode plot', movie_image: 'still.jpg' }, + } as never, + language: 'en', + seriesTitle: 'Signal House', + seasonNumber: 0, + episodeNumber: '0', + }); + + expect(snapshot).toEqual( + expect.objectContaining({ + mediaKind: 'series', + title: 'Signal House', + cast: [{ name: 'Sienna Wave' }], + creators: [{ name: 'Cora Bell' }], + episode: expect.objectContaining({ + seasonNumber: 0, + episodeNumber: 0, + title: 'The Call', + }), + }) + ); + }); + + it.each([ + [Number.NaN, 2], + [1, -1], + [1.5, 2], + ])('omits episode metadata for malformed row coordinates', (season, ep) => { + const snapshot = createStalkerSeriesDownloadSnapshot({ + item: { id: '50', info: { name: 'Signal House' } } as never, + episode: { title: 'The Call', info: [] } as never, + language: 'en', + seriesTitle: 'Signal House', + seasonNumber: season, + episodeNumber: ep, + }); + + expect(snapshot.mediaKind).toBe('series'); + expect(snapshot.episode).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts new file mode 100644 index 000000000..d1bce3bcc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download-metadata.ts @@ -0,0 +1,102 @@ +import type { + DownloadMetadataPerson, + DownloadMetadataSnapshot, + TmdbEnrichedCastMember, + XtreamSerieEpisode, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import type { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, +} from '@iptvnator/portal/shared/util'; + +interface StalkerSeriesDownloadSnapshotInput { + item: StalkerSelectedVodItem; + episode: XtreamSerieEpisode; + language: string; + seriesTitle: string; + /** Values already selected for the persisted download row. */ + seasonNumber: unknown; + episodeNumber: unknown; +} + +function textList(value: string | undefined): string[] | undefined { + const entries = value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries?.length ? entries : undefined; +} + +function finiteNumber(value: string | number | undefined) { + if (value === undefined || String(value).trim() === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function releaseYear(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function people( + enriched: TmdbEnrichedCastMember[] | undefined, + fallback: string | undefined +): DownloadMetadataPerson[] | undefined { + return enriched?.length + ? enriched.map((person) => ({ + name: person.name, + role: person.character, + profileUrl: person.profileUrl ?? undefined, + tmdbPersonId: person.tmdbPersonId, + })) + : textList(fallback)?.map((name) => ({ name })); +} + +function rowCoordinate(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function createStalkerSeriesDownloadSnapshot( + input: StalkerSeriesDownloadSnapshotInput +): DownloadMetadataSnapshot { + const info = input.item.info; + const common = { + language: input.language, + title: input.seriesTitle.trim() || 'Series', + originalTitle: info?.o_name, + plot: info?.description, + releaseDate: info?.releasedate, + year: releaseYear(info?.releasedate), + genres: textList(info?.genre), + rating: finiteNumber(info?.rating_imdb), + status: info?.tmdb_status, + posterUrl: info?.movie_image, + backdropUrl: info?.tmdb_backdrop, + tmdbId: info?.tmdb_id, + providerCategoryId: input.item.category_id, + cast: people(info?.tmdb_cast, info?.actors), + creators: people(info?.tmdb_directors, info?.director), + }; + const seasonNumber = rowCoordinate(input.seasonNumber); + const episodeNumber = rowCoordinate(input.episodeNumber); + if (seasonNumber === undefined || episodeNumber === undefined) { + return { ...createMovieDownloadSnapshot(common), mediaKind: 'series' }; + } + + const episodeInfo = Array.isArray(input.episode.info) + ? undefined + : (input.episode.info as XtreamSerieEpisodeInfo); + return createSeriesEpisodeDownloadSnapshot({ + ...common, + episode: { + seasonNumber, + episodeNumber, + title: input.episode.title, + plot: episodeInfo?.plot, + stillUrl: episodeInfo?.movie_image, + }, + }); +} 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 07ae93308..0256b9952 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 @@ -42,7 +42,9 @@
@for ( member of serial.info.tmdb_cast; - track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (serial.info.tmdb_directors?.length) {
- @for (member of serial.info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + @for ( + member of serial.info.tmdb_directors; + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { {{ - action.labelKey - | translate: action.labelParams + action.labelKey | translate: action.labelParams }} @if (action.episodeLabel) { @@ -193,6 +201,7 @@ inlinePlayback()?.contentInfo?.contentXtreamId ?? null " [seasonDescriptions]="seasonDescriptions()" + [downloadsEnabled]="!providerOnly()" [isLoading]=" isVodSeriesSeasonsLoading() || isCurrentSeasonLoading(seasonContainer?.selectedSeason()) @@ -209,10 +218,7 @@
@if (similarInPortals().length) { -
+

{{ 'XTREAM.SIMILAR' | translate }}

@for ( @@ -223,9 +229,7 @@ type="button" class="details__similar-card" [title]=" - item.title + - ' — ' + - item.match.playlistName + item.title + ' — ' + item.match.playlistName " (click)="openSimilarInPortals(item)" > @@ -263,7 +267,15 @@ 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 dbccdd37a..f256c1bd3 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 @@ -38,6 +38,7 @@ class StubSeasonContainerComponent { readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); readonly isLoading = input(false); + readonly downloadsEnabled = input(true); readonly seasonSelected = output(); readonly episodeClicked = output(); readonly episodeDownloadRequested = output(); @@ -89,6 +90,13 @@ describe('StalkerSeriesViewComponent', () => { const openResolvedPlayback = jest.fn(); const isEmbeddedPlayer = jest.fn(); const tmdbGetSeason = jest.fn(); + const startDownload = jest.fn(); + const fetchLinkToPlay = jest.fn(); + const currentPlaylist = signal({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }); beforeEach(async () => { selectedContentType.set('series'); @@ -122,7 +130,7 @@ describe('StalkerSeriesViewComponent', () => { episodeId?: number, startTime?: number ) => ({ - streamUrl: 'http://stalker.example/episode.mpg', + streamUrl: 'http://stalker.example/episode.mpg', title: title ?? 'Regular Series', thumbnail: thumbnail ?? 'poster.jpg', startTime, @@ -147,6 +155,15 @@ describe('StalkerSeriesViewComponent', () => { overview: 'Season overview from TMDB', episodes: [], }); + startDownload.mockReset().mockResolvedValue({ success: true }); + fetchLinkToPlay + .mockReset() + .mockResolvedValue('https://cdn.example.test/episode.mpg'); + currentPlaylist.set({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }); await TestBed.configureTestingModule({ imports: [StalkerSeriesViewComponent], @@ -156,7 +173,7 @@ describe('StalkerSeriesViewComponent', () => { useValue: { selectedItem, selectedContentType, - currentPlaylist: signal({ _id: 'stalker-1' }), + currentPlaylist, getSerialSeasonsResource: () => serialSeasonsResource(), getVodSeriesSeasonsResource: () => vodSeriesSeasonsResource(), @@ -164,7 +181,7 @@ describe('StalkerSeriesViewComponent', () => { isSerialSeasonsLoading, fetchVodSeriesEpisodes, resolveVodPlayback, - fetchLinkToPlay: jest.fn(), + fetchLinkToPlay, clearSelectedItem: jest.fn(), }, }, @@ -198,7 +215,7 @@ describe('StalkerSeriesViewComponent', () => { { provide: DownloadsService, useValue: { - startDownload: jest.fn(), + startDownload, }, }, { @@ -221,6 +238,8 @@ describe('StalkerSeriesViewComponent', () => { instant: (key: string) => key, get: (key: string) => of(key), stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', // EMPTY (not of(null)): the real TranslatePipe inside // the detail shell reads `event.lang` from emissions. onLangChange: EMPTY, @@ -305,6 +324,92 @@ describe('StalkerSeriesViewComponent', () => { ); }); + it('keeps provider episodes playable while hiding download presentation', async () => { + fixture.componentRef.setInput('providerOnly', true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + expect(seasonContainer.downloadsEnabled()).toBe(false); + expect( + Object.values( + seasonContainer.seasons() as Record + ).flat() + ).toHaveLength(2); + expect( + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ) + ).not.toBeNull(); + }); + + it('captures enriched parent and episode metadata when an episode download starts', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + category_id: '18', + info: { + name: 'Signal House', + description: 'Parent series plot', + movie_image: + 'https://images.example.test/posters/signal-house.jpg', + actors: 'Sienna Wave', + director: 'Cora Bell', + tmdb_cast: [], + tmdb_directors: [], + }, + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + await fixture.componentInstance.downloadEpisode({ + episode_num: '0', + title: 'The Call', + info: { + plot: 'Episode-specific plot', + movie_image: 'https://images.example.test/stills/the-call.jpg', + }, + custom_sid: 'vod-series', + season: '0', + originalId: '502', + } as never); + + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + playlistId: 'stalker-1', + playlistType: 'stalker', + seriesXtreamId: 50001, + seasonNumber: 0, + episodeNumber: '0', + title: 'Signal House - S00E00 - The Call', + metadataSnapshot: expect.objectContaining({ + language: 'en', + mediaKind: 'series', + title: 'Signal House', + plot: 'Parent series plot', + posterUrl: + 'https://images.example.test/posters/signal-house.jpg', + providerCategoryId: '18', + cast: [{ name: 'Sienna Wave' }], + creators: [{ name: 'Cora Bell' }], + episode: { + seasonNumber: 0, + episodeNumber: 0, + title: 'The Call', + plot: 'Episode-specific plot', + stillUrl: + 'https://images.example.test/stills/the-call.jpg', + }, + }), + }) + ); + }); + it('interpolates the episode number for a recently started VOD is_series episode', async () => { selectedContentType.set('vod'); selectedItem.set({ @@ -614,10 +719,12 @@ describe('StalkerSeriesViewComponent', () => { ]); fetchVodSeriesEpisodes.mockClear(); - const seasonOneEpisodes = fixture.componentInstance.mappedSeasons()['1']; + const seasonOneEpisodes = + fixture.componentInstance.mappedSeasons()['1']; const firstEpisode = seasonOneEpisodes[0]; const secondEpisode = seasonOneEpisodes[1]; - const seasonTwoEpisode = fixture.componentInstance.mappedSeasons()['2'][0]; + const seasonTwoEpisode = + fixture.componentInstance.mappedSeasons()['2'][0]; fixture.componentInstance.onEpisodeClicked(firstEpisode); await fixture.whenStable(); 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 f21b184a6..319968b54 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 @@ -29,6 +29,7 @@ import { seriesStatusLabelKey, TmdbEnrichedCastMember, XtreamSerieEpisode, + type XtreamSerieEpisodeInfo, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { SafePipe } from '@iptvnator/pipes'; @@ -82,6 +83,7 @@ import { saveStalkerSeriesPosition, StalkerSeriesPositionPartialSaveError, } from './stalker-series-position-compatibility'; +import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata'; interface SeriesPositionContext { readonly generation: number; @@ -90,6 +92,48 @@ interface SeriesPositionContext { readonly mutationKey: string; } +function snapshotTextList(value: string | undefined): string[] | undefined { + const entries = value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries?.length ? entries : undefined; +} + +function snapshotNumber( + value: string | number | undefined +): number | undefined { + if (value === undefined || String(value).trim() === '') { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function snapshotYear(value: string | undefined): number | undefined { + const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); + return match ? Number(match[1]) : undefined; +} + +function snapshotPeople( + enriched: TmdbEnrichedCastMember[] | undefined, + fallback: string | undefined +) { + return enriched?.length + ? enriched.map((person) => ({ + name: person.name, + role: person.character, + profileUrl: person.profileUrl ?? undefined, + tmdbPersonId: person.tmdbPersonId, + })) + : snapshotTextList(fallback)?.map((name) => ({ name })); +} + +function positiveCoordinate(value: number | string | undefined): number { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1; +} + /** * Component for displaying series/episodes for Stalker portal content. * Supports three modes: @@ -181,6 +225,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { * When provided, uses this instead of fetching seasons from API */ readonly vodWithSeries = input(null); + readonly providerOnly = input(false); readonly selectedItem = this.stalkerStore.selectedItem; @@ -197,8 +242,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { */ readonly similarInPortals = signal([]); private readonly loadSimilarInPortals = effect(() => { - const recommendations = - this.displayItem()?.info?.tmdb_recommendations; + const recommendations = this.displayItem()?.info?.tmdb_recommendations; untracked(() => { this.similarInPortals.set([]); if ( @@ -293,21 +337,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { const seasons = this.mappedSeasons(); const episodes = seasonKey ? seasons[seasonKey] : undefined; if (tmdbId && seasonKey && !seasonsLoading && episodes?.length) { - untracked(() => - void this.tmdbSeasons.fetchSeason( - tmdbId, - seasonKey, - episodes, - { - // The season marker can live in either title - // field (generic name + descriptive o_name) - rawTitle: pickSeasonMarkedTitle( - item?.info?.name, - item?.info?.o_name - ), - seasonCount: Object.keys(seasons).length, - } - ) + untracked( + () => + void this.tmdbSeasons.fetchSeason( + tmdbId, + seasonKey, + episodes, + { + // The season marker can live in either title + // field (generic name + descriptive o_name) + rawTitle: pickSeasonMarkedTitle( + item?.info?.name, + item?.info?.o_name + ), + seasonCount: Object.keys(seasons).length, + } + ) ); } }); @@ -596,19 +641,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { // before the answer arrives. A failure releases the claim below. this.prefetchedSpilloverSeasonIds.add(prefetchKey); - untracked(() => - void this.loadEpisodesForSeason(nextSeason).then((answered) => { - if (answered) { - this.spilloverPrefetchFailure = null; - return; - } + untracked( + () => + void this.loadEpisodesForSeason(nextSeason).then((answered) => { + if (answered) { + this.spilloverPrefetchFailure = null; + return; + } - this.spilloverPrefetchFailure = { - key: prefetchKey, - episodeId, - }; - this.prefetchedSpilloverSeasonIds.delete(prefetchKey); - }) + this.spilloverPrefetchFailure = { + key: prefetchKey, + episodeId, + }; + this.prefetchedSpilloverSeasonIds.delete(prefetchKey); + }) ); }); @@ -955,10 +1001,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback( - resolvedPlayback, - true - ); + void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true); } catch (error) { this.logger.error('Failed to start inline series playback', error); const errorMessage = @@ -1078,6 +1121,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { title: episodeTitle, url, posterUrl, + metadataSnapshot: createStalkerSeriesDownloadSnapshot({ + item, + episode, + language: + this.translateService.currentLang || + this.translateService.defaultLang || + 'en', + seriesTitle, + seasonNumber: seasonNum, + episodeNumber: episodeNum, + }), seriesXtreamId: this.toSeriesId(item.id), seasonNumber: seasonNum, episodeNumber: episodeNum, @@ -1457,11 +1511,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { private getEpisodeInfo( episode: XtreamSerieEpisode - ): { movie_image?: string } | undefined { + ): XtreamSerieEpisodeInfo | undefined { if (!episode.info || Array.isArray(episode.info)) { return undefined; } - return episode.info as { movie_image?: string }; + return episode.info; } private getEpisodeDownloadId(episode: XtreamSerieEpisode): number { diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts index 5df070cc8..872f2a88e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -132,6 +132,37 @@ describe('enrichVodSelectionWithTmdb', () => { expect(store.setSelectedItem).not.toHaveBeenCalled(); }); + it('drops a same-id result after its playlist request becomes stale', async () => { + let isCurrentRequest = true; + const store = createStore(vodItem); + const enrichment = createEnrichment({ + enrichMovie: jest.fn().mockImplementation(async () => { + isCurrentRequest = false; + store.replaceItem({ + ...vodItem, + info: { + ...vodItem.info, + name: 'Movie from playlist B', + plot: 'Playlist B plot', + }, + }); + return { id: 603, overview: 'Playlist A TMDB overview' }; + }), + } as Partial); + + await enrichVodSelectionWithTmdb( + store, + enrichment, + '42', + () => isCurrentRequest + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect(store.selectedItem()?.['info']).toEqual( + expect.objectContaining({ plot: 'Playlist B plot' }) + ); + }); + it('keeps provider data when no confident match was found', async () => { const store = createStore(vodItem); const enrichment = createEnrichment(); @@ -174,6 +205,37 @@ describe('enrichSerialSelectionWithTmdb', () => { expect(enrichment.enrichTv).not.toHaveBeenCalled(); }); + + it('drops a same-id result after its playlist request becomes stale', async () => { + let isCurrentRequest = true; + const store = createStore(serialItem); + const enrichment = createEnrichment({ + enrichTv: jest.fn().mockImplementation(async () => { + isCurrentRequest = false; + store.replaceItem({ + ...serialItem, + info: { + ...serialItem.info, + name: 'Series from playlist B', + plot: 'Playlist B plot', + }, + }); + return { id: 70523, overview: 'Playlist A TMDB overview' }; + }), + } as Partial); + + await enrichSerialSelectionWithTmdb( + store, + enrichment, + '7', + () => isCurrentRequest + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect(store.selectedItem()?.['info']).toEqual( + expect.objectContaining({ plot: 'Playlist B plot' }) + ); + }); }); describe('enrichSerialSeasonWithTmdb', () => { @@ -250,4 +312,41 @@ describe('enrichSerialSeasonWithTmdb', () => { expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); }); + + it('drops a same-id season result after its playlist becomes stale', async () => { + let isCurrentPlaylist = true; + const store = createStore( + seasonSliceItem('Series from playlist A', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockImplementation(async () => { + isCurrentPlaylist = false; + store.replaceItem( + seasonSliceItem('Series from playlist B', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + return [{ episode_number: 1, name: 'Playlist A episode' }]; + }), + } as Partial); + + await enrichSerialSeasonWithTmdb( + store, + enrichment, + '1', + () => isCurrentPlaylist + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect( + ( + store.selectedItem()?.['episodes'] as Record< + string, + { title: string }[] + > + )['1'][0].title + ).toBe('Episode 1'); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 8d32d389d..084177448 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -32,13 +32,17 @@ type SelectionRecord = { readonly [key: string]: unknown }; export async function enrichVodSelectionWithTmdb( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - vodId: string | number + vodId: string | number, + isCurrentRequest: () => boolean = () => true ): Promise { if (!enrichment.isEnabled()) { return; } const isCurrent = (): TItem | null => { + if (!isCurrentRequest()) { + return null; + } const item = store.selectedItem(); return item && String(item['stream_id']) === String(vodId) ? item @@ -87,13 +91,17 @@ export async function enrichSerialSelectionWithTmdb< >( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - serialId: string | number + serialId: string | number, + isCurrentRequest: () => boolean = () => true ): Promise { if (!enrichment.isEnabled()) { return; } const isCurrent = (): TItem | null => { + if (!isCurrentRequest()) { + return null; + } const item = store.selectedItem(); return item && String(item['series_id']) === String(serialId) ? item @@ -136,20 +144,18 @@ export async function enrichSerialSelectionWithTmdb< * enrichSerialSelectionWithTmdb). Merges real episode names, overviews and * stills into `episodes[seasonKey]` by episode number. */ -export async function enrichSerialSeasonWithTmdb< - TItem extends SelectionRecord, ->( +export async function enrichSerialSeasonWithTmdb( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - seasonKey: string + seasonKey: string, + isCurrentSelection: () => boolean = () => true ): Promise { - if (!enrichment.isEnabled()) { + if (!enrichment.isEnabled() || !isCurrentSelection()) { return; } const selected = store.selectedItem() as unknown as - | (XtreamSerieDetails & { series_id?: string | number }) - | null; + (XtreamSerieDetails & { series_id?: string | number }) | null; const serialId = selected?.series_id; const info = selected?.info; const episodes = selected?.episodes?.[seasonKey]; @@ -180,13 +186,12 @@ export async function enrichSerialSeasonWithTmdb< info.tmdb_id, seasonNumber ); - if (!tmdbEpisodes?.length) { + if (!tmdbEpisodes?.length || !isCurrentSelection()) { return; } const current = store.selectedItem() as unknown as - | (XtreamSerieDetails & { series_id?: string | number }) - | null; + (XtreamSerieDetails & { series_id?: string | number }) | null; const currentEpisodes = current?.episodes?.[seasonKey]; if ( !current || diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts index 7e2b8bba7..5feb04d4e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -57,6 +57,7 @@ async function waitForCondition( function configureStore( xtreamApiService: { + getSeriesInfo?: jest.Mock; getVodInfo: jest.Mock; getVodStream: jest.Mock; }, @@ -240,4 +241,112 @@ describe('XtreamStore VOD details recovery', () => { expect(store.detailsError()).toBeNull(); expect(store.isLoadingDetails()).toBe(false); }); + + it('clears the previous movie while a reused-route request is pending or fails', async () => { + const nextDetails = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn(() => nextDetails.promise), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + store.setSelectedItem({ + info: { name: 'Movie A' }, + stream_id: 42, + xtream_id: 42, + }); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + + expect(store.selectedItem()).toBeNull(); + + nextDetails.reject(new Error('Movie B unavailable')); + await waitForCondition( + () => store.detailsError() === 'Movie B unavailable' + ); + + expect(store.selectedItem()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); + + it('keeps the newer movie authoritative when an older base request resolves late', async () => { + const firstDetails = createDeferred<{ + container_extension: string; + info: { name: string }; + stream_id: number; + }>(); + const xtreamApiService = { + getVodInfo: jest.fn((_credentials: unknown, vodId: string) => + vodId === '42' + ? firstDetails.promise + : Promise.resolve({ + info: { name: 'Movie B' }, + stream_id: 99, + container_extension: 'mp4', + }) + ), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.streamId === 99 + ); + + firstDetails.resolve({ + info: { name: 'Movie A' }, + stream_id: 42, + container_extension: 'mkv', + }); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 99, + containerExtension: 'mp4', + }); + expect(store.detailsError()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); + + it('clears the previous series while a reused-route request is pending or fails', async () => { + const nextDetails = createDeferred(); + const xtreamApiService = { + getSeriesInfo: jest.fn(() => nextDetails.promise), + getVodInfo: jest.fn(), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + store.setSelectedItem({ + info: { name: 'Series A' }, + series_id: 42, + }); + + store.fetchSerialDetailsWithMetadata({ + categoryId: 7, + serialId: '99', + }); + + expect(store.selectedItem()).toBeNull(); + + nextDetails.reject(new Error('Series B unavailable')); + await waitForCondition( + () => store.detailsError() === 'Series B unavailable' + ); + + expect(store.selectedItem()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 7a807cc3e..fe8ca8df9 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -143,6 +143,7 @@ export const XtreamStore = signalStore( store.setIsLoadingDetails(true); store.setDetailsError(null); + store.setSelectedItem(null); xtreamApiService .getVodInfo(credentials, params.vodId) .then((vodDetails: XtreamVodDetails) => { @@ -171,7 +172,8 @@ export const XtreamStore = signalStore( void enrichVodSelectionWithTmdb( store, tmdbEnrichment, - params.vodId + params.vodId, + isCurrentRequest ); if (!recovery) { @@ -241,6 +243,7 @@ export const XtreamStore = signalStore( store.setIsLoadingDetails(true); store.setDetailsError(null); + store.setSelectedItem(null); xtreamApiService .getSeriesInfo(credentials, params.serialId) .then((serialDetails: XtreamSerieDetails) => { @@ -253,7 +256,8 @@ export const XtreamStore = signalStore( void enrichSerialSelectionWithTmdb( store, tmdbEnrichment, - params.serialId + params.serialId, + isCurrentRequest ); }) .catch((error: unknown) => { @@ -278,10 +282,15 @@ export const XtreamStore = signalStore( * opens a season; a no-op without a show-level TMDB match. */ enrichSelectedSerialSeason(seasonKey: string): void { + const playlistId = store.currentPlaylist()?.id; + if (!playlistId) { + return; + } void enrichSerialSeasonWithTmdb( store, tmdbEnrichment, - seasonKey + seasonKey, + () => store.currentPlaylist()?.id === playlistId ); }, }; 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 3069de5d8..09d4b6898 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 @@ -42,7 +42,12 @@ {{ 'XTREAM.CAST' | translate }} @if (info.tmdb_cast?.length) {
- @for (member of info.tmdb_cast; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + @for ( + member of info.tmdb_cast; + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { - {{ 'XTREAM.DIRECTOR' | translate }} + {{ + 'XTREAM.DIRECTOR' | translate + }} @if (info.tmdb_directors?.length) {
- @for (member of info.tmdb_directors; track member.tmdbPersonId ? 'p' + member.tmdbPersonId : 'i' + $index) { + @for ( + member of info.tmdb_directors; + track member.tmdbPersonId + ? 'p' + member.tmdbPersonId + : 'i' + $index + ) { {{ - action.labelKey - | translate: action.labelParams + action.labelKey | translate: action.labelParams }} @@ -195,6 +206,8 @@ [seriesTitle]="info.name" [playbackPositions]="episodePlaybackPositions()" [xtreamDownloadContext]="xtreamDownloadContext()" + [downloadMetadataContext]="downloadMetadataContext()" + [downloadsEnabled]="!providerOnly()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" [playingEpisodeId]=" @@ -239,15 +252,16 @@ } @for ( item of similarInPortals(); - track 'x' + item.match.playlistId + '-' + item.match.xtreamId + track 'x' + + item.match.playlistId + + '-' + + item.match.xtreamId ) { - @if (!isExternalStopAction()) { + @if (externalPrimaryButtonState() === 'idle' && !isDownloaded()) { } - @if (!isFavorite()) { - - } @else { - - } - @if (isElectron()) { - @if (isDownloaded()) { - - } @else if (isPausedDownload()) { - - } @else if (isDownloading()) { - } @else { } - } - - @if (multiSource.hasAlternatives()) { - - } - - @if (activeSourceCaption(); as caption) { -

- - {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} - - {{ caption.source }} - @if (multiSource.hasAlternatives()) { - - @if (caption.alternativeCount === 1) { + @if (isElectron() && !providerOnly()) { + @if (isDownloaded()) { + @if (externalPrimaryButtonState() === 'idle') { + + } + } @else if (isPausedDownload()) { + + } @else if (isDownloading()) { + + } @else { + } -

+ } + + @if (multiSource.hasAlternatives()) { + + } + + @if (activeSourceCaption(); as caption) { +

+ + {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} + + {{ + caption.source + }} + @if (multiSource.hasAlternatives()) { + + @if (caption.alternativeCount === 1) { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' + | translate + }} + } @else { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER' + | translate + : { + count: caption.alternativeCount, + } + }} + } + + } +

+ } } } @@ -210,6 +237,11 @@ @if (info.country) { {{ info.country }} } + @if (isDownloaded()) { + + {{ 'DOWNLOADS.OFFLINE' | translate }} + + } @@ -451,6 +483,11 @@ @if (fallback.format) { {{ fallback.format }} } + @if (isDownloaded()) { + + {{ 'DOWNLOADS.OFFLINE' | translate }} + + } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index b2c4723de..62660a0d3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -345,7 +345,7 @@ describe('VodDetailsRouteComponent', () => { rating_kinopoisk: '7.455', }, movie_data: { - stream_id: 678140, + stream_id: 650020, name: 'City of McFarland (2015) DE', added: '1750671180', category_id: '235', @@ -373,7 +373,7 @@ describe('VodDetailsRouteComponent', () => { movie_image: 'https://example.com/poster.jpg', }, movie_data: { - stream_id: 678140, + stream_id: 650020, name: 'Metadata Without Backdrop', added: '1750671180', category_id: '235', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 343c3bd6e..ed5489759 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -23,7 +23,10 @@ import { VodSourcesChipComponent, } from '@iptvnator/ui/components'; import { SafePipe } from '@iptvnator/pipes'; -import { createLogger } from '@iptvnator/portal/shared/util'; +import { + createLogger, + isProviderOnlyDetailState, +} from '@iptvnator/portal/shared/util'; import { resolveXtreamVodPlaybackSource, XtreamStore, @@ -68,6 +71,26 @@ import { VodDetailsSimilarService } from './vod-details-similar.service'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; +type XtreamVodIdentityItem = XtreamVodDetails & { + readonly id?: number | string; + readonly stream_id?: number | string; + readonly xtream_id?: number | string; +}; + +function resolveVodIdentity(item: XtreamVodDetails): number | null { + const candidate = item as XtreamVodIdentityItem; + const value = + item.movie_data?.stream_id ?? + candidate.xtream_id ?? + candidate.stream_id ?? + candidate.id; + const id = typeof value === 'string' ? Number(value) : value; + + return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 + ? id + : null; +} + @Component({ templateUrl: './vod-details-route.component.html', styleUrls: [ @@ -134,11 +157,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isElectron = this.downloadsService.isAvailable; readonly isFavorite = this.xtreamStore.isFavorite; - readonly selectedItem = computed( - () => - this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null - ); readonly selectedVodId = computed(() => Number(this.routeParams().vodId)); + readonly providerOnly = computed(() => { + this.routeParams(); + return isProviderOnlyDetailState(window.history.state); + }); + readonly selectedItem = computed(() => { + const item = + this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null; + + return item && resolveVodIdentity(item) === this.selectedVodId() + ? item + : null; + }); private readonly scopedVodCategories = computed(() => { const playlistId = this.xtreamStore.currentPlaylist()?.id; return playlistId && @@ -268,9 +299,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition; - readonly isDownloaded = this.downloads.isDownloaded; + private readonly downloadedFromLibrary = this.downloads.isDownloaded; + readonly isDownloaded = computed( + () => !this.providerOnly() && this.downloadedFromLibrary() + ); readonly isDownloading = this.downloads.isDownloading; readonly isPausedDownload = this.downloads.isPausedDownload; + readonly isOfflinePrimary = computed( + () => + this.isDownloaded() && this.externalPrimaryButtonState() === 'idle' + ); readonly trailerEmbedUrl = computed(() => youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer) @@ -475,7 +513,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // make the control do the opposite of what it says — launching a // second player while the first keeps running. if (this.playback.isExternalStopAction()) { - this.playback.onPrimaryAction(vodItem); + await this.playback.stopExternalPlayback(); + return; + } + + if (this.playback.isExternalLaunchPending()) { + return; + } + + if (this.isDownloaded()) { + await this.playFromLocal(); + return; + } + + await this.playFromProviderSource(vodItem); + } + + async playFromProviderSource( + vodItem: XtreamVodDetails | null + ): Promise { + if (this.externalPrimaryButtonState() !== 'idle') { return; } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index 4b1125acc..eb2922000 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -17,7 +17,7 @@ import { XtreamVodStream, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { VodDetailsRouteComponent } from './vod-details-route.component'; /** @@ -32,6 +32,10 @@ import { VodDetailsRouteComponent } from './vod-details-route.component'; /** Every stub the harness installs, so specs can drive and assert on them. */ export function createVodDetailsRouteStubs() { return { + routeParams: new BehaviorSubject({ + vodId: '650020', + categoryId: '235', + }), selectedItem: signal(null), isLoadingDetails: signal(false), detailsError: signal(null), @@ -54,7 +58,14 @@ export function createVodDetailsRouteStubs() { addRecentItem: jest.fn(), cancelDetailsRequest: jest.fn(), vodStreamsPlaylistId: signal(null), + downloadsAvailable: signal(false), downloads: signal([]), + isDownloaded: jest.fn().mockReturnValue(false), + isDownloading: jest.fn().mockReturnValue(false), + isPausedDownload: jest.fn().mockReturnValue(false), + resumeDownloadByContent: jest.fn().mockResolvedValue(undefined), + getDownloadedFilePath: jest.fn(), + playDownload: jest.fn().mockResolvedValue(undefined), getPlaybackPosition: jest.fn().mockResolvedValue(null), savePlaybackPosition: jest.fn().mockResolvedValue(undefined), activeSession: signal(null), @@ -75,6 +86,7 @@ export type VodDetailsRouteStubs = ReturnType< /** Back to the state a fresh `beforeEach` expects. */ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void { + stubs.routeParams.next({ vodId: '650020', categoryId: '235' }); stubs.selectedItem.set(null); stubs.isLoadingDetails.set(false); stubs.detailsError.set(null); @@ -82,6 +94,7 @@ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void { stubs.currentPlaylist.set(null); stubs.vodStreams.set([]); stubs.vodCategories.set([]); + stubs.downloadsAvailable.set(false); stubs.activeSession.set(null); stubs.selectedPlayer.set(VideoPlayer.Html5Player); stubs.updateSettings.mockClear().mockResolvedValue(undefined); @@ -91,6 +104,11 @@ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void { value.mockClear(); } } + + stubs.isDownloaded.mockReturnValue(false); + stubs.isDownloading.mockReturnValue(false); + stubs.isPausedDownload.mockReturnValue(false); + stubs.getDownloadedFilePath.mockReturnValue(undefined); } /** @@ -140,7 +158,7 @@ export async function configureVodDetailsRouteTestBed( { provide: ActivatedRoute, useValue: { - params: of({ vodId: '650020', categoryId: '235' }), + params: stubs.routeParams, snapshot: { params: { vodId: '650020', categoryId: '235' }, }, @@ -191,13 +209,15 @@ export async function configureVodDetailsRouteTestBed( { provide: DownloadsService, useValue: { - isAvailable: signal(false), + isAvailable: stubs.downloadsAvailable, downloads: stubs.downloads, - isDownloaded: jest.fn().mockReturnValue(false), - isDownloading: jest.fn().mockReturnValue(false), + isDownloaded: stubs.isDownloaded, + isDownloading: stubs.isDownloading, + isPaused: stubs.isPausedDownload, + resumeDownloadByContent: stubs.resumeDownloadByContent, startDownload: stubs.startDownload, - getDownloadedFilePath: jest.fn(), - playDownload: jest.fn(), + getDownloadedFilePath: stubs.getDownloadedFilePath, + playDownload: stubs.playDownload, }, }, { diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index 9bd1a8d82..6407a92e5 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -9,6 +9,11 @@ const loadDownloadsComponent: ComponentLoader = () => (c) => c.DownloadsComponent ); +const loadDownloadOfflineDetailComponent: ComponentLoader = () => + import('@iptvnator/portal/downloads/feature').then( + (c) => c.DownloadOfflineDetailComponent + ); + const loadXtreamContentGateComponent: ComponentLoader = () => import('./xtream-content-gate.component').then( (c) => c.XtreamContentGateComponent @@ -83,11 +88,13 @@ export function createXtreamRoutes(): Route[] { children: [ { path: '', - loadComponent: loadCategoryContentViewComponent, + loadComponent: + loadCategoryContentViewComponent, }, { path: ':categoryId', - loadComponent: loadCategoryContentViewComponent, + loadComponent: + loadCategoryContentViewComponent, }, { path: ':categoryId/:vodId', @@ -101,11 +108,13 @@ export function createXtreamRoutes(): Route[] { children: [ { path: '', - loadComponent: loadCategoryContentViewComponent, + loadComponent: + loadCategoryContentViewComponent, }, { path: ':categoryId', - loadComponent: loadCategoryContentViewComponent, + loadComponent: + loadCategoryContentViewComponent, }, { path: ':categoryId/:serialId', @@ -137,6 +146,10 @@ export function createXtreamRoutes(): Route[] { loadComponent: loadXtreamCollectionRouteComponent, data: { mode: 'recent', portalType: 'xtream' }, }, + { + path: 'downloads/:downloadId', + loadComponent: loadDownloadOfflineDetailComponent, + }, { path: 'downloads', loadComponent: loadDownloadsComponent, diff --git a/libs/services/src/lib/downloads-metadata-update.ts b/libs/services/src/lib/downloads-metadata-update.ts new file mode 100644 index 000000000..bf911ed08 --- /dev/null +++ b/libs/services/src/lib/downloads-metadata-update.ts @@ -0,0 +1,29 @@ +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; + +interface DownloadMetadataHost { + isAvailable(): boolean; + loadDownloads(): Promise; +} + +export async function updateDownloadMetadata( + host: DownloadMetadataHost, + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot +): Promise<{ success: boolean; error?: string }> { + if (!host.isAvailable()) return { success: false }; + + try { + const result = await window.electron.downloadsUpdateMetadata( + downloadId, + metadataSnapshot + ); + if (result.success) await host.loadDownloads(); + return result; + } catch (error) { + console.error('[DownloadsService] Metadata update error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts new file mode 100644 index 000000000..96aa81596 --- /dev/null +++ b/libs/services/src/lib/downloads.models.ts @@ -0,0 +1,36 @@ +import type { + DownloadMetadataSnapshot, + ElectronBridgeDownloadStartPayload, + ElectronDownloadFileAvailability, +} from '@iptvnator/shared/interfaces'; + +export type DownloadStatus = + 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; + +export type DownloadStartInput = Omit< + ElectronBridgeDownloadStartPayload, + 'downloadFolder' +>; + +export interface DownloadItem { + id: number; + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + title: string; + url: string; + fileName?: string; + filePath?: string; + fileAvailability?: ElectronDownloadFileAvailability; + posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; + status: DownloadStatus; + bytesDownloaded?: number; + totalBytes?: number; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; +} diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 7455302e3..8a77a54c9 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -7,10 +7,11 @@ import { runInInjectionContext, signal, } from '@angular/core'; -import { - DownloadItem, - DownloadsService, -} from './downloads.service'; +import type { + DownloadMetadataSnapshot, + ElectronBridgeDownloadStartPayload, +} from '@iptvnator/shared/interfaces'; +import { DownloadItem, DownloadsService } from './downloads.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; type TestDownloadsService = { @@ -19,11 +20,15 @@ type TestDownloadsService = { isAvailable: () => boolean; isLoadingDownloads: Signal; hasLoadedDownloads: Signal; + getDownload: DownloadsService['getDownload']; loadDownloads: DownloadsService['loadDownloads']; loadDownloadFolder: DownloadsService['loadDownloadFolder']; pauseDownload: DownloadsService['pauseDownload']; + redownloadMissing: DownloadsService['redownloadMissing']; resumeDownload: DownloadsService['resumeDownload']; selectFolder: DownloadsService['selectFolder']; + startDownload: DownloadsService['startDownload']; + updateMetadata: DownloadsService['updateMetadata']; _isLoadingDownloads: WritableSignal; _hasLoadedDownloads: WritableSignal; loadDownloadsRequestId: number; @@ -31,13 +36,33 @@ type TestDownloadsService = { type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; + downloadsStart?: jest.Mock< + Promise<{ success: boolean; id?: number; error?: string }>, + [ElectronBridgeDownloadStartPayload] + >; + downloadsUpdateMetadata?: jest.Mock< + Promise<{ success: boolean; error?: string }>, + [number, DownloadMetadataSnapshot] + >; downloadsPause?: jest.Mock, [number]>; + downloadsRedownloadMissing?: jest.Mock< + Promise<{ success: boolean; recovered?: boolean }>, + [number] + >; downloadsResume?: jest.Mock< Promise<{ success: boolean }>, [number, string] >; downloadsSelectFolder?: jest.Mock, []>; - downloadsGetList: jest.Mock, [string?]>; + downloadsGetList: jest.Mock, []>; +}; + +const metadataSnapshot: DownloadMetadataSnapshot = { + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Offline Movie', + plot: 'Stored for offline details.', }; describe('DownloadsService', () => { @@ -51,7 +76,10 @@ describe('DownloadsService', () => { jest.restoreAllMocks(); }); - function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem { + function createDownload( + id: number, + playlistId = 'playlist-1' + ): DownloadItem { return { id, playlistId, @@ -74,6 +102,18 @@ describe('DownloadsService', () => { return { promise, resolve, reject }; } + function createMetadataUpdateMock(result: { + success: boolean; + error?: string; + }) { + return jest + .fn< + Promise<{ success: boolean; error?: string }>, + [number, DownloadMetadataSnapshot] + >() + .mockResolvedValue(result); + } + function createService(initialDownloads: DownloadItem[] = []) { const downloads = signal(initialDownloads); const isLoadingDownloads = signal(false); @@ -97,6 +137,15 @@ describe('DownloadsService', () => { return service; } + it('exposes a download metadata snapshot on list items', () => { + const item: DownloadItem = { + ...createDownload(42), + metadataSnapshot, + }; + + expect(item.metadataSnapshot).toBe(metadataSnapshot); + }); + it('reports availability through the runtime capability', () => { const injector = createEnvironmentInjector( [ @@ -121,7 +170,7 @@ describe('DownloadsService', () => { } }); - it('tracks loading and loaded state around a successful download list request', async () => { + it('loads the global download list without a playlist filter and tracks request state', async () => { const item = createDownload(1); const pending = createDeferred(); const electron = { @@ -130,11 +179,11 @@ describe('DownloadsService', () => { testWindow.electron = electron; const service = createService(); - const request = service.loadDownloads('playlist-1'); + const request = service.loadDownloads(); expect(service.isLoadingDownloads()).toBe(true); expect(service.hasLoadedDownloads()).toBe(false); - expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1'); + expect(electron.downloadsGetList).toHaveBeenCalledWith(); pending.resolve([item]); await request; @@ -157,6 +206,45 @@ describe('DownloadsService', () => { expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1); }); + it('forwards a metadata snapshot unchanged when starting a download', async () => { + const electron = { + downloadsGetDefaultFolder: jest.fn(async () => '/downloads'), + downloadsGetList: jest.fn(async () => []), + downloadsStart: jest + .fn< + Promise<{ id: number; success: boolean }>, + [ElectronBridgeDownloadStartPayload] + >() + .mockResolvedValue({ id: 42, success: true }), + }; + testWindow.electron = electron; + const service = createService(); + + await expect( + service.startDownload({ + playlistId: 'playlist-1', + xtreamId: 7, + contentType: 'vod', + title: 'Offline Movie', + url: 'https://example.com/movie.mp4', + metadataSnapshot, + }) + ).resolves.toEqual({ id: 42, success: true }); + + expect(electron.downloadsStart).toHaveBeenCalledWith({ + playlistId: 'playlist-1', + xtreamId: 7, + contentType: 'vod', + title: 'Offline Movie', + url: 'https://example.com/movie.mp4', + metadataSnapshot, + downloadFolder: '/downloads', + }); + expect(electron.downloadsStart.mock.calls[0][0].metadataSnapshot).toBe( + metadataSnapshot + ); + }); + it('stores a selected download folder returned by the main process', async () => { const electron = { downloadsSelectFolder: jest.fn(async () => '/selected'), @@ -203,9 +291,177 @@ describe('DownloadsService', () => { await expect(service.resumeDownload(42)).resolves.toEqual({ success: true, }); - expect(electron.downloadsResume).toHaveBeenCalledWith( + expect(electron.downloadsResume).toHaveBeenCalledWith(42, '/downloads'); + }); + + it('re-downloads a missing completed file by managed id', async () => { + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsRedownloadMissing: jest.fn(async (downloadId: number) => ({ + success: downloadId === 42, + })), + }; + testWindow.electron = electron; + const service = createService() as unknown as DownloadsService; + + await expect(service.redownloadMissing(42)).resolves.toEqual({ + success: true, + }); + expect(electron.downloadsRedownloadMissing).toHaveBeenCalledWith(42); + }); + + it('gets a download from the current signal by managed id', () => { + const matching = { + ...createDownload(42), + metadataSnapshot, + }; + const service = createService([createDownload(7), matching]); + + expect(service.getDownload(42)).toBe(matching); + expect(service.getDownload(99)).toBeUndefined(); + }); + + it('updates metadata by managed id and reloads the authoritative list on success', async () => { + const refreshed = { + ...createDownload(42), + metadataSnapshot, + }; + const electron = { + downloadsGetList: jest.fn(async () => [refreshed]), + downloadsUpdateMetadata: createMetadataUpdateMock({ + success: true, + }), + }; + testWindow.electron = electron; + const service = createService([createDownload(42)]); + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ success: true }); + + expect(electron.downloadsUpdateMetadata).toHaveBeenCalledWith( 42, - '/downloads' + metadataSnapshot + ); + expect(electron.downloadsGetList).toHaveBeenCalledTimes(1); + expect(service.downloads()).toEqual([refreshed]); + }); + + it('returns a metadata update bridge failure without reloading', async () => { + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsUpdateMetadata: createMetadataUpdateMock({ + error: 'Metadata rejected', + success: false, + }), + }; + testWindow.electron = electron; + const service = createService(); + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ + error: 'Metadata rejected', + success: false, + }); + expect(electron.downloadsGetList).not.toHaveBeenCalled(); + }); + + it('does not touch the bridge when metadata updates are unavailable', async () => { + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsUpdateMetadata: createMetadataUpdateMock({ + success: true, + }), + }; + testWindow.electron = electron; + const service = createService(); + service.isAvailable = () => false; + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ success: false }); + expect(electron.downloadsUpdateMetadata).not.toHaveBeenCalled(); + expect(electron.downloadsGetList).not.toHaveBeenCalled(); + }); + + it('returns a safe failure when the metadata update bridge throws', async () => { + const error = new Error('metadata update failed'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsUpdateMetadata: jest + .fn< + Promise<{ success: boolean; error?: string }>, + [number, DownloadMetadataSnapshot] + >() + .mockRejectedValue(error), + }; + testWindow.electron = electron; + const service = createService(); + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ + error: 'metadata update failed', + success: false, + }); + expect(electron.downloadsGetList).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + '[DownloadsService] Metadata update error:', + error + ); + }); + + it('stringifies a non-Error metadata update failure', async () => { + const error = { message: 404 }; + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsUpdateMetadata: jest + .fn< + Promise<{ success: boolean; error?: string }>, + [number, DownloadMetadataSnapshot] + >() + .mockRejectedValue(error), + }; + testWindow.electron = electron; + const service = createService(); + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ + error: '[object Object]', + success: false, + }); + expect(console.error).toHaveBeenCalledWith( + '[DownloadsService] Metadata update error:', + error + ); + }); + + it('returns a safe failure when the metadata reload throws', async () => { + const error = new Error('metadata reload failed'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsUpdateMetadata: createMetadataUpdateMock({ + success: true, + }), + }; + testWindow.electron = electron; + const service = createService(); + service.loadDownloads = jest.fn().mockRejectedValue(error); + + await expect( + service.updateMetadata(42, metadataSnapshot) + ).resolves.toEqual({ + error: 'metadata reload failed', + success: false, + }); + expect(console.error).toHaveBeenCalledWith( + '[DownloadsService] Metadata update error:', + error ); }); @@ -245,8 +501,8 @@ describe('DownloadsService', () => { testWindow.electron = electron; const service = createService(); - const firstRequest = service.loadDownloads('playlist-old'); - const secondRequest = service.loadDownloads('playlist-new'); + const firstRequest = service.loadDownloads(); + const secondRequest = service.loadDownloads(); expect(service.isLoadingDownloads()).toBe(true); @@ -262,14 +518,8 @@ describe('DownloadsService', () => { expect(service.downloads()).toEqual([latestItem]); expect(service.isLoadingDownloads()).toBe(false); - expect(electron.downloadsGetList).toHaveBeenNthCalledWith( - 1, - 'playlist-old' - ); - expect(electron.downloadsGetList).toHaveBeenNthCalledWith( - 2, - 'playlist-new' - ); + expect(electron.downloadsGetList).toHaveBeenNthCalledWith(1); + expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2); }); it('reports paused content and resumes it by content identity', async () => { @@ -281,9 +531,7 @@ describe('DownloadsService', () => { pausedItem, createDownload(8), ]) as unknown as DownloadsService; - const resumeDownload = jest - .fn() - .mockResolvedValue({ success: true }); + const resumeDownload = jest.fn().mockResolvedValue({ success: true }); (service as unknown as { resumeDownload: jest.Mock }).resumeDownload = resumeDownload; @@ -296,6 +544,32 @@ describe('DownloadsService', () => { expect(resumeDownload).toHaveBeenCalledWith(7); }); + it('only exposes completed files that are currently available', () => { + const available = { + ...createDownload(20), + fileAvailability: 'available' as const, + filePath: '/downloads/available.mp4', + }; + const missing = { + ...createDownload(21), + fileAvailability: 'missing' as const, + filePath: '/downloads/missing.mp4', + }; + const service = createService([ + available, + missing, + ]) as unknown as DownloadsService; + + expect(service.isDownloaded(20, 'playlist-1', 'vod')).toBe(true); + expect(service.getDownloadedFilePath(20, 'playlist-1', 'vod')).toBe( + '/downloads/available.mp4' + ); + expect(service.isDownloaded(21, 'playlist-1', 'vod')).toBe(false); + expect( + service.getDownloadedFilePath(21, 'playlist-1', 'vod') + ).toBeUndefined(); + }); + it('rejects resume-by-content when the download is not paused', async () => { const service = createService([ createDownload(8), diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 92c7e4fe3..e2c702c7a 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,34 +1,11 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; +import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import { updateDownloadMetadata } from './downloads-metadata-update'; +import type { DownloadItem, DownloadStartInput } from './downloads.models'; +import { formatDownloadBytes } from './downloads.utils'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -export type DownloadStatus = - | 'queued' - | 'downloading' - | 'paused' - | 'completed' - | 'failed' - | 'canceled'; - -export interface DownloadItem { - id: number; - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - title: string; - url: string; - fileName?: string; - filePath?: string; - posterUrl?: string; - status: DownloadStatus; - bytesDownloaded?: number; - totalBytes?: number; - errorMessage?: string; - createdAt?: string; - updatedAt?: string; -} +export type { DownloadItem, DownloadStatus } from './downloads.models'; @Injectable({ providedIn: 'root' }) export class DownloadsService implements OnDestroy { @@ -106,14 +83,14 @@ export class DownloadsService implements OnDestroy { /** * Load downloads from the backend */ - async loadDownloads(playlistId?: string): Promise { + async loadDownloads(): Promise { if (!this.isAvailable()) return; const requestId = ++this.loadDownloadsRequestId; this._isLoadingDownloads.set(true); try { - const list = await window.electron.downloadsGetList(playlistId); + const list = await window.electron.downloadsGetList(); if (requestId === this.loadDownloadsRequestId) { this.downloads.set(list); this._hasLoadedDownloads.set(true); @@ -156,24 +133,9 @@ export class DownloadsService implements OnDestroy { /** * Start a new download */ - async startDownload(data: { - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - title: string; - url: string; - posterUrl?: string; - headers?: { userAgent?: string; referer?: string; origin?: string }; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - // Playlist info for auto-creation if needed (Stalker playlists) - playlistName?: string; - playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; - serverUrl?: string; - portalUrl?: string; - macAddress?: string; - }): Promise<{ success: boolean; id?: number; error?: string }> { + async startDownload( + data: DownloadStartInput + ): Promise<{ success: boolean; id?: number; error?: string }> { if (!this.isAvailable()) { return { success: false, error: 'Downloads not available' }; } @@ -286,9 +248,30 @@ export class DownloadsService implements OnDestroy { try { return await window.electron.downloadsRetry(downloadId, folder); + } catch (error) { + console.error('[DownloadsService] Error retrying download:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Re-download a completed item whose finalized file is unavailable. + */ + async redownloadMissing( + downloadId: number + ): Promise<{ success: boolean; recovered?: boolean; error?: string }> { + if (!this.isAvailable()) { + return { success: false, error: 'Downloads not available' }; + } + + try { + return await window.electron.downloadsRedownloadMissing(downloadId); } catch (error) { console.error( - '[DownloadsService] Error retrying download:', + '[DownloadsService] Error re-downloading missing file:', error ); return { @@ -311,10 +294,7 @@ export class DownloadsService implements OnDestroy { try { return await window.electron.downloadsRemove(downloadId); } catch (error) { - console.error( - '[DownloadsService] Error removing download:', - error - ); + console.error('[DownloadsService] Error removing download:', error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -322,6 +302,14 @@ export class DownloadsService implements OnDestroy { } } + /** Update the offline metadata snapshot for a managed download. */ + async updateMetadata( + id: number, + snapshot: DownloadMetadataSnapshot + ): Promise<{ success: boolean; error?: string }> { + return updateDownloadMetadata(this, id, snapshot); + } + /** * Play a downloaded file */ @@ -379,10 +367,7 @@ export class DownloadsService implements OnDestroy { } return folder; } catch (error) { - console.error( - '[DownloadsService] Error selecting folder:', - error - ); + console.error('[DownloadsService] Error selecting folder:', error); return null; } } @@ -390,9 +375,7 @@ export class DownloadsService implements OnDestroy { /** * Clear completed/failed downloads */ - async clearCompleted( - playlistId?: string - ): Promise<{ success: boolean }> { + async clearCompleted(playlistId?: string): Promise<{ success: boolean }> { if (!this.isAvailable()) { return { success: false }; } @@ -420,6 +403,13 @@ export class DownloadsService implements OnDestroy { ); } + /** + * Get a download item by managed id + */ + getDownload(downloadId: number): DownloadItem | undefined { + return this.downloads().find(({ id }) => id === downloadId); + } + /** * Get download item by xtreamId and playlistId */ @@ -444,8 +434,12 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); - return download?.status === 'completed' && !!download.filePath; + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); + return this.hasAvailableCompletedFile(download); } /** @@ -456,7 +450,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); return ( download?.status === 'downloading' || download?.status === 'queued' || @@ -472,7 +470,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); return download?.status === 'paused'; } @@ -484,7 +486,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): Promise<{ success: boolean; error?: string }> { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); if (!download || download.status !== 'paused') { return { success: false, error: 'No paused download found' }; } @@ -499,21 +505,31 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): string | undefined { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); - if (download?.status === 'completed') { + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); + if (this.hasAvailableCompletedFile(download)) { return download.filePath; } return undefined; } + private hasAvailableCompletedFile( + item: DownloadItem | undefined + ): item is DownloadItem & { filePath: string } { + return ( + item?.status === 'completed' && + !!item.filePath && + item.fileAvailability !== 'missing' + ); + } + /** * Format bytes to human readable string */ formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return formatDownloadBytes(bytes); } } diff --git a/libs/services/src/lib/downloads.utils.ts b/libs/services/src/lib/downloads.utils.ts new file mode 100644 index 000000000..ec5257953 --- /dev/null +++ b/libs/services/src/lib/downloads.utils.ts @@ -0,0 +1,11 @@ +export function formatDownloadBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const kilobyte = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const index = Math.floor(Math.log(bytes) / Math.log(kilobyte)); + return ( + parseFloat((bytes / Math.pow(kilobyte, index)).toFixed(1)) + + ' ' + + sizes[index] + ); +} diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 02b73edd2..824fb9969 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -100,6 +100,7 @@ describe('RuntimeCapabilitiesService', () => { downloadsPause: jest.fn(), downloadsResume: jest.fn(), downloadsRetry: jest.fn(), + downloadsRedownloadMissing: jest.fn(), downloadsRemove: jest.fn(), downloadsGetList: jest.fn(), downloadsGet: jest.fn(), @@ -382,6 +383,13 @@ describe('RuntimeCapabilitiesService', () => { onDownloadsUpdate: jest.fn(), }; + expect(service.supportsDownloads).toBe(false); + + testWindow.electron = { + ...testWindow.electron, + downloadsRedownloadMissing: jest.fn(), + }; + expect(service.supportsDownloads).toBe(true); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index caae0e069..0ebb4ecf3 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -186,6 +186,7 @@ export class RuntimeCapabilitiesService { 'downloadsPause', 'downloadsResume', 'downloadsRetry', + 'downloadsRedownloadMissing', 'downloadsRemove', 'downloadsGetList', 'downloadsGet', @@ -278,8 +279,7 @@ export class RuntimeCapabilitiesService { private hasElectronMethod(methodName: string): boolean { const bridge = this.electronBridge as - | Record - | undefined; + Record | undefined; return typeof bridge?.[methodName] === 'function'; } diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 031faf932..cde717a06 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -1,4 +1,11 @@ +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { getTableColumns } from 'drizzle-orm'; +import { getTableConfig } from 'drizzle-orm/sqlite-core'; import { __databaseConnectionTestHooks } from './connection'; +import { categories, downloads } from './schema'; function compactSql(statement: string): string { return statement.replace(/\s+/g, ' ').trim(); @@ -17,6 +24,117 @@ function createdObjectNames(prefix: string, statements: readonly string[]) { .filter((name): name is string => Boolean(name)); } +function rebuildDownloadsInElectron(metadataSnapshot: string): { + metadataSnapshot: string; + retainedAfterPlaylistDelete: boolean; + schemaSql: string; +} { + const electronPath = createRequire(__filename)('electron') as string; + const connectionUrl = pathToFileURL( + resolve(__dirname, 'connection.ts') + ).href; + const script = ` + const { default: Database } = await import('better-sqlite3'); + const { __databaseConnectionTestHooks } = await import(${JSON.stringify( + connectionUrl + )}); + const metadataSnapshot = ${JSON.stringify(metadataSnapshot)}; + const sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec(\` + CREATE TABLE playlists (id TEXT PRIMARY KEY); + CREATE TABLE downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + content_type TEXT NOT NULL, + series_xtream_id INTEGER, + season_number INTEGER, + episode_number INTEGER, + title TEXT NOT NULL, + url TEXT NOT NULL, + file_name TEXT, + file_path TEXT, + poster_url TEXT, + request_headers TEXT, + metadata_snapshot TEXT, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ( + 'queued', + 'downloading', + 'completed', + 'failed', + 'canceled' + )), + bytes_downloaded INTEGER, + total_bytes INTEGER, + error_message TEXT, + created_at TEXT, + updated_at TEXT + , FOREIGN KEY (playlist_id) REFERENCES playlists (id) + ON DELETE CASCADE + ); + \`); + sqlite.prepare('INSERT INTO playlists (id) VALUES (?)') + .run('playlist-1'); + sqlite.prepare(\` + INSERT INTO downloads ( + playlist_id, + xtream_id, + content_type, + title, + url, + metadata_snapshot + ) VALUES (?, ?, ?, ?, ?, ?) + \`).run( + 'playlist-1', + 42, + 'vod', + 'Offline title', + 'https://example.com/movie', + metadataSnapshot + ); + console.log = () => undefined; + __databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite); + const row = sqlite.prepare( + 'SELECT metadata_snapshot FROM downloads WHERE id = 1' + ).get(); + const table = sqlite.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'" + ).get(); + sqlite.prepare('DELETE FROM playlists WHERE id = ?') + .run('playlist-1'); + const retainedAfterPlaylistDelete = Boolean(sqlite.prepare( + 'SELECT id FROM downloads WHERE id = 1' + ).get()); + sqlite.close(); + process.stdout.write(JSON.stringify({ + metadataSnapshot: row.metadata_snapshot, + retainedAfterPlaylistDelete, + schemaSql: table.sql, + })); + `; + const output = execFileSync( + electronPath, + ['--import', 'tsx', '--eval', script], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'), + }, + } + ); + + return JSON.parse(output) as { + metadataSnapshot: string; + retainedAfterPlaylistDelete: boolean; + schemaSql: string; + }; +} + describe('database schema statements', () => { afterEach(() => { jest.restoreAllMocks(); @@ -85,6 +203,17 @@ describe('database schema statements', () => { it('defines the core fresh-install tables, indexes, and FTS triggers', () => { const schemaSql = createTableStatements.map(compactSql).join('\n'); + const downloadsSchemaSql = + createTableStatements + .map(compactSql) + .find((statement) => + statement.startsWith('CREATE TABLE IF NOT EXISTS downloads') + ) ?? ''; + const downloadColumns = Object.values(getTableColumns(downloads)).map( + (column) => column.name + ); + const categoryForeignKeys = getTableConfig(categories).foreignKeys; + const downloadForeignKeys = getTableConfig(downloads).foreignKeys; expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists'); expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories'); @@ -102,8 +231,15 @@ describe('database schema statements', () => { 'CREATE TABLE IF NOT EXISTS playback_positions' ); expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads'); + expect(downloadsSchemaSql).not.toContain( + 'FOREIGN KEY (playlist_id) REFERENCES playlists' + ); + expect(categoryForeignKeys).toHaveLength(1); + expect(downloadForeignKeys).toHaveLength(0); expect(schemaSql).toContain('request_headers TEXT'); expect(schemaSql).toContain('resume_validator TEXT'); + expect(schemaSql).toContain('metadata_snapshot TEXT'); + expect(downloadColumns).toContain('metadata_snapshot'); expect(schemaSql).toContain("'paused'"); expect(schemaSql).toContain( 'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique' @@ -149,6 +285,9 @@ describe('database schema statements', () => { 'ALTER TABLE content ADD COLUMN backdrop_url TEXT', ]) ); + expect(columnMigrationStatements).toContain( + 'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT' + ); }); it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => { @@ -488,7 +627,8 @@ describe('database schema statements', () => { expect(statements[createIndex]).toContain('request_headers TEXT'); expect(statements[createIndex]).toContain('resume_validator TEXT'); expect(statements[copyIndex]).toContain('NULL AS request_headers'); - expect(statements[copyIndex]).not.toContain('resume_validator'); + expect(statements[copyIndex]).toContain('NULL AS metadata_snapshot'); + expect(statements[copyIndex]).toContain('NULL AS resume_validator'); }); it('copies stored headers when only the paused status is missing', () => { @@ -506,6 +646,42 @@ describe('database schema statements', () => { expect(copy).toContain('request_headers'); }); + it('preserves metadata snapshots when rebuilding the pause/resume schema', () => { + const metadataSnapshot = JSON.stringify({ + version: 1, + language: 'en', + mediaKind: 'movie', + title: 'Offline title', + }); + const rebuilt = rebuildDownloadsInElectron(metadataSnapshot); + + expect(rebuilt.schemaSql).toContain(`'paused'`); + expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists'); + expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot); + expect(rebuilt.retainedAfterPlaylistDelete).toBe(true); + }); + + it('rebuilds a current downloads table that still cascades with its source playlist', () => { + const sqlite = createRebuildSqlite( + `CREATE TABLE downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + request_headers TEXT, + resume_validator TEXT, + status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')) + )` + ); + + ensureDownloadsPauseResumeSchema(sqlite); + + expect(sqlite.transaction).toHaveBeenCalledTimes(1); + const replacement = sqlite.statements.find((statement) => + statement.startsWith('CREATE TABLE IF NOT EXISTS downloads') + ); + expect(replacement).toBeDefined(); + expect(replacement).not.toContain('REFERENCES playlists'); + }); + it('skips the downloads rebuild when the table already has the paused contract', () => { const sqlite = createRebuildSqlite( `CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))` @@ -518,8 +694,7 @@ describe('database schema statements', () => { }); it('backfills migrated EPG program source URLs in bounded batches', () => { - const { backfillEpgProgramSourceUrls } = - __databaseConnectionTestHooks; + const { backfillEpgProgramSourceUrls } = __databaseConnectionTestHooks; let updateStatement = ''; const backfillRun = jest .fn() diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 0721cf11f..043222882 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -105,13 +105,13 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads ( poster_url TEXT, request_headers TEXT, resume_validator TEXT, + metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')), bytes_downloaded INTEGER DEFAULT 0, total_bytes INTEGER, error_message TEXT, created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE + updated_at TEXT DEFAULT (datetime('now')) )`; const DOWNLOADS_INDEX_STATEMENTS = [ `CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`, @@ -379,6 +379,8 @@ const COLUMN_MIGRATION_STATEMENTS = [ `ALTER TABLE epg_programs ADD COLUMN source_url TEXT`, // Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume `ALTER TABLE downloads ADD COLUMN resume_validator TEXT`, + // Offline details: provider-neutral display metadata captured at download time + `ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`, ]; const INDEX_MIGRATION_STATEMENTS = [ @@ -579,8 +581,7 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void { const migrationState = sqliteDb .prepare(`SELECT value FROM app_state WHERE key = ?`) .get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as - | { value?: unknown } - | undefined; + { value?: unknown } | undefined; if (migrationState?.value === 'done') { return; @@ -697,8 +698,7 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean { const migrationState = sqliteDb .prepare(`SELECT value FROM app_state WHERE key = ?`) .get(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY) as - | { value?: unknown } - | undefined; + { value?: unknown } | undefined; // The marker alone is not evidence. `createTables` declares this table // too, with the plain tokenizer, so a table recreated by that path @@ -759,8 +759,7 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void { const migrationState = sqliteDb .prepare(`SELECT value FROM app_state WHERE key = ?`) .get(CONTENT_TITLE_FTS_MIGRATION_KEY) as - | { value?: unknown } - | undefined; + { value?: unknown } | undefined; if (migrationState?.value === 'done') { return; @@ -803,8 +802,7 @@ function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void { const migrationState = sqliteDb .prepare(`SELECT value FROM app_state WHERE key = ?`) .get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as - | { value?: unknown } - | undefined; + { value?: unknown } | undefined; if (migrationState?.value === 'done') { return; @@ -938,8 +936,7 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void { const migrationState = sqliteDb .prepare(`SELECT value FROM app_state WHERE key = ?`) .get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as - | { value?: unknown } - | undefined; + { value?: unknown } | undefined; if (migrationState?.value === 'done') { return; @@ -978,9 +975,9 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void { } /** - * Existing downloads tables had a status CHECK without 'paused'. SQLite cannot - * alter CHECK constraints in place, so rebuild only when the table SQL still - * reflects the old contract or lacks request_headers. + * Downloads remain locally owned after their source playlist is removed. + * SQLite cannot alter CHECK or foreign-key constraints in place, so rebuild + * when the table still has the old pause/header contract or source cascade. */ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { try { @@ -995,13 +992,24 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { const hasPausedStatus = row.sql.includes(`'paused'`); const hasRequestHeaders = row.sql.includes('request_headers'); - if (hasPausedStatus && hasRequestHeaders) { + const hasPlaylistForeignKey = /\bREFERENCES\s+["`[]?playlists\b/i.test( + row.sql + ); + if (hasPausedStatus && hasRequestHeaders && !hasPlaylistForeignKey) { return; } const legacyHeadersSelect = hasRequestHeaders ? 'request_headers' : 'NULL AS request_headers'; + const hasMetadataSnapshot = row.sql.includes('metadata_snapshot'); + const legacyMetadataSnapshotSelect = hasMetadataSnapshot + ? 'metadata_snapshot' + : 'NULL AS metadata_snapshot'; + const hasResumeValidator = row.sql.includes('resume_validator'); + const legacyResumeValidatorSelect = hasResumeValidator + ? 'resume_validator' + : 'NULL AS resume_validator'; const rebuild = sqliteDb.transaction(() => { for (const statement of DOWNLOADS_INDEX_STATEMENTS) { const match = statement.match( @@ -1034,6 +1042,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { file_path, poster_url, request_headers, + resume_validator, + metadata_snapshot, status, bytes_downloaded, total_bytes, @@ -1055,6 +1065,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { file_path, poster_url, ${legacyHeadersSelect}, + ${legacyResumeValidatorSelect}, + ${legacyMetadataSnapshotSelect}, status, bytes_downloaded, total_bytes, @@ -1064,18 +1076,16 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void { FROM downloads_pause_resume_legacy` ) .run(); - sqliteDb - .prepare(`DROP TABLE downloads_pause_resume_legacy`) - .run(); + sqliteDb.prepare(`DROP TABLE downloads_pause_resume_legacy`).run(); for (const statement of DOWNLOADS_INDEX_STATEMENTS) { sqliteDb.prepare(statement).run(); } }); rebuild(); - console.log('[DB] Rebuilt downloads table with pause/resume schema'); + console.log('[DB] Rebuilt downloads table with local ownership schema'); } catch (error) { - console.warn('[DB] downloads pause/resume migration failed:', error); + console.warn('[DB] downloads ownership migration failed:', error); } } diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index e87f3cbb4..e8b716ec4 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -115,9 +115,7 @@ export const content = sqliteTable( categoryIdx: index('idx_content_category').on(table.categoryId), titleIdx: index('idx_content_title').on(table.title), xtreamIdx: index('idx_content_xtream').on(table.xtreamId), - epgChannelIdx: index('idx_content_epg_channel').on( - table.epgChannelId - ), + epgChannelIdx: index('idx_content_epg_channel').on(table.epgChannelId), categoryTypeXtreamUnique: uniqueIndex( 'content_category_type_xtream_unique' ).on(table.categoryId, table.type, table.xtreamId), @@ -315,9 +313,7 @@ export const downloads = sqliteTable( 'downloads', { id: integer('id').primaryKey({ autoIncrement: true }), - playlistId: text('playlist_id') - .notNull() - .references(() => playlists.id, { onDelete: 'cascade' }), + playlistId: text('playlist_id').notNull(), // Content identifiers xtreamId: integer('xtream_id').notNull(), contentType: text('content_type', { @@ -335,6 +331,7 @@ export const downloads = sqliteTable( posterUrl: text('poster_url'), requestHeaders: text('request_headers'), resumeValidator: text('resume_validator'), + metadataSnapshot: text('metadata_snapshot'), // Download progress status: text('status', { enum: [ @@ -376,7 +373,9 @@ export const tmdbMetadata = sqliteTable( 'tmdb_metadata', { id: integer('id').primaryKey({ autoIncrement: true }), - mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(), + mediaType: text('media_type', { + enum: ['movie', 'tv', 'person'], + }).notNull(), lookupKey: text('lookup_key').notNull(), language: text('language').notNull(), tmdbId: integer('tmdb_id'), diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 290bea8ca..83bbe81d4 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/channel-drm.interface'; export * from './lib/channel.interface'; export * from './lib/channel.model'; export * from './lib/dev-logger.util'; +export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; diff --git a/libs/shared/interfaces/src/lib/download-metadata.interface.ts b/libs/shared/interfaces/src/lib/download-metadata.interface.ts new file mode 100644 index 000000000..4d6d8193b --- /dev/null +++ b/libs/shared/interfaces/src/lib/download-metadata.interface.ts @@ -0,0 +1,37 @@ +export interface DownloadMetadataPerson { + tmdbPersonId?: number; + name: string; + role?: string; + profileUrl?: string; +} + +export interface DownloadEpisodeMetadata { + title?: string; + plot?: string; + stillUrl?: string; + seasonNumber: number; + episodeNumber: number; +} + +export interface DownloadMetadataSnapshot { + version: 1; + language: string; + mediaKind: 'movie' | 'series'; + title: string; + originalTitle?: string; + plot?: string; + releaseDate?: string; + year?: number; + durationMinutes?: number; + genres?: string[]; + rating?: number; + status?: string; + posterUrl?: string; + backdropUrl?: string; + tmdbId?: number; + providerCategoryId?: string; + cast?: DownloadMetadataPerson[]; + creators?: DownloadMetadataPerson[]; + episode?: DownloadEpisodeMetadata; + enrichedAt?: string; +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index e8905a06e..44023a9cc 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -4,6 +4,7 @@ import { EmbeddedMpvSession, EmbeddedMpvSupport, } from './embedded-mpv-session.interface'; +import { DownloadMetadataSnapshot } from './download-metadata.interface'; import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; import { ExternalPlayerSession } from './external-player-session.interface'; @@ -137,6 +138,9 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = { export type ElectronBridgeDownloadStatus = (typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES]; +export type ElectronDownloadFileAvailability = + 'available' | 'missing' | 'not-applicable'; + export const ELECTRON_BRIDGE_APP_UPDATE_STATUSES = { Unsupported: 'unsupported', Idle: 'idle', @@ -386,8 +390,7 @@ export interface ElectronBridgePlaylistRow { } export type ElectronBridgePlaylistUpsertInput = - | Playlist - | ElectronBridgePlaylistInput; + Playlist | ElectronBridgePlaylistInput; export interface ElectronBridgeCategoryRow { id: number; @@ -514,6 +517,7 @@ export interface ElectronBridgeDownloadStartPayload { title: string; url: string; posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; downloadFolder: string; headers?: ElectronBridgeDownloadHeaders; seriesXtreamId?: number; @@ -530,6 +534,10 @@ export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorRe id?: number; } +export interface ElectronBridgeDownloadRedownloadResult extends ElectronBridgeErrorResult { + recovered?: boolean; +} + export interface ElectronDownloadItem { id: number; playlistId: string; @@ -543,7 +551,9 @@ export interface ElectronDownloadItem { fileName?: string; filePath?: string; posterUrl?: string; + metadataSnapshot?: DownloadMetadataSnapshot; status: ElectronBridgeDownloadStatus; + fileAvailability: ElectronDownloadFileAvailability; bytesDownloaded?: number; totalBytes?: number; errorMessage?: string; @@ -928,9 +938,7 @@ export interface ElectronBridgeApi { playlistId: string, pins: VodSourcePin[] ) => Promise; - dbClearVodSourcePin: ( - matchKeys: string[] - ) => Promise; + dbClearVodSourcePin: (matchKeys: string[]) => Promise; onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void ) => () => void; @@ -1072,9 +1080,16 @@ export interface ElectronBridgeApi { downloadId: number, downloadFolder: string ) => Promise; + downloadsRedownloadMissing: ( + downloadId: number + ) => Promise; downloadsRemove: (downloadId: number) => Promise; downloadsGetList: (playlistId?: string) => Promise; downloadsGet: (downloadId: number) => Promise; + downloadsUpdateMetadata: ( + downloadId: number, + metadataSnapshot: DownloadMetadataSnapshot + ) => Promise; downloadsGetDefaultFolder: () => Promise; downloadsSelectFolder: () => Promise; downloadsRevealFile: ( diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 66950ef10..44b5ea4b7 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -1,8 +1,13 @@
- @if (errorMessage()) {
@@ -91,17 +96,21 @@
+ > + @if (backdropImageUrl(); as backdropImage) { + + } +
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index a44db8110..73ca7f5c8 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -104,6 +104,14 @@ z-index: 0; overflow: hidden; + &-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 20%; + } + // Fallback blur effect when using poster as backdrop &--blurred { filter: blur(40px) saturate(1.3); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts index eee853896..8c7aa1203 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from './content-hero.component'; describe('ContentHeroComponent', () => { @@ -20,6 +20,9 @@ describe('ContentHeroComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(ContentHeroComponent); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { BACK: 'Go back' }); + translate.use('en'); }); it('renders description content when ResizeObserver is unavailable', () => { @@ -38,4 +41,89 @@ describe('ContentHeroComponent', () => { expect(host.textContent).toContain('Fallback Title'); expect(host.textContent).toContain('Plain description'); }); + + it('renders an accessible button with the translated fallback back label', () => { + fixture.detectChanges(); + + const back = (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement; + expect(back.type).toBe('button'); + expect(back.getAttribute('aria-label')).toBe('Go back'); + }); + + it('uses an explicit back label when supplied', () => { + fixture.componentRef.setInput('backLabel', 'Back to downloads'); + fixture.detectChanges(); + + const back = (fixture.nativeElement as HTMLElement).querySelector( + '.hero__back-button' + ) as HTMLButtonElement; + expect(back.getAttribute('aria-label')).toBe('Back to downloads'); + }); + + it('resets a poster failure when the poster URL changes', () => { + fixture.componentRef.setInput('posterUrl', 'broken.jpg'); + fixture.detectChanges(); + const first = (fixture.nativeElement as HTMLElement).querySelector( + '.poster img[src="broken.jpg"]' + ) as HTMLImageElement; + first.dispatchEvent(new Event('error')); + fixture.detectChanges(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.poster img[src="broken.jpg"]' + ) + ).toBeNull(); + + fixture.componentRef.setInput('posterUrl', 'replacement.jpg'); + fixture.detectChanges(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.poster img[src="replacement.jpg"]' + ) + ).toBeTruthy(); + }); + + it('keeps an explicit backdrop visible when the poster image fails', () => { + fixture.componentRef.setInput('posterUrl', 'broken-poster.jpg'); + fixture.componentRef.setInput('backdropUrl', 'working-backdrop.jpg'); + fixture.detectChanges(); + + const poster = (fixture.nativeElement as HTMLElement).querySelector( + 'img[src="broken-poster.jpg"]' + ) as HTMLImageElement; + poster.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'img.hero__backdrop-image[src="working-backdrop.jpg"]' + ) + ).toBeTruthy(); + }); + + it('falls back the backdrop independently without hiding a valid poster', () => { + fixture.componentRef.setInput('posterUrl', 'working-poster.jpg'); + fixture.componentRef.setInput('backdropUrl', 'broken-backdrop.jpg'); + fixture.detectChanges(); + + const backdrop = (fixture.nativeElement as HTMLElement).querySelector( + 'img.hero__backdrop-image' + ) as HTMLImageElement | null; + expect(backdrop).toBeTruthy(); + backdrop?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.poster img[src="working-poster.jpg"]' + ) + ).toBeTruthy(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + 'img.hero__backdrop-image' + ) + ).toBeNull(); + }); }); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 8a18585b8..a3448e145 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -37,17 +37,37 @@ export class ContentHeroComponent { readonly backdropUrl = input(); readonly isLoading = input(false); readonly errorMessage = input(); + readonly backLabel = input(); readonly backClicked = output(); readonly posterError = signal(false); + private readonly failedBackdropUrl = signal(undefined); + readonly backdropSourceUrl = computed( + () => this.backdropUrl() || this.posterUrl() + ); + readonly backdropError = computed(() => { + const source = this.backdropSourceUrl(); + return !!source && this.failedBackdropUrl() === source; + }); + readonly backdropImageUrl = computed(() => + this.backdropError() ? undefined : this.backdropSourceUrl() + ); + readonly usesPosterBackdrop = computed( + () => !this.backdropUrl() && !!this.posterUrl() && !this.backdropError() + ); - readonly descriptionEl = viewChild>('descriptionEl'); + readonly descriptionEl = + viewChild>('descriptionEl'); readonly isDescriptionExpanded = signal(false); readonly hasDescriptionOverflow = signal(false); private resizeObserver?: ResizeObserver; constructor() { + effect(() => { + this.posterUrl(); + untracked(() => this.posterError.set(false)); + }); effect(() => { // Re-measure whenever description content or the element changes. this.description(); @@ -70,6 +90,10 @@ export class ContentHeroComponent { this.posterError.set(true); } + onBackdropError(): void { + this.failedBackdropUrl.set(this.backdropSourceUrl()); + } + readonly formattedTitle = computed(() => { const t = this.title(); if (!t) return ''; @@ -120,7 +144,9 @@ export class ContentHeroComponent { this.measureOverflow(el); return; } - this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el)); + this.resizeObserver = new ResizeObserver(() => + this.measureOverflow(el) + ); this.resizeObserver.observe(el); } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html index 4a0c1f51d..c92425d62 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html @@ -8,6 +8,7 @@ [backdropUrl]="backdropUrl()" [isLoading]="isLoading()" [errorMessage]="errorMessage()" + [backLabel]="backLabel()" (backClicked)="backClicked.emit()" > diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 8c2bb2d74..12860bca2 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -21,6 +21,7 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component'; [title]="'Show Title'" [description]="'Show description'" [posterUrl]="'poster.jpg'" + [backLabel]="'Return to downloads'" [playbackActive]="playbackActive()" (closePlayerRequested)="closeRequests = closeRequests + 1" > @@ -72,6 +73,9 @@ describe('PortalDetailShellComponent', () => { expect(query('.details__meta .details__meta-item')).toBeTruthy(); expect(query('.action-buttons .play-btn')).toBeTruthy(); expect(query('app-content-about')).toBeNull(); + expect(query('.hero__back-button')?.getAttribute('aria-label')).toBe( + 'Return to downloads' + ); }); it('collapses hero and shows About with re-stamped templates in watch state', () => { diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index 1113b1cfe..492c5de20 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -50,6 +50,7 @@ export class PortalDetailShellComponent { readonly backdropUrl = input(); readonly isLoading = input(false); readonly errorMessage = input(); + readonly backLabel = input(); /** True while inline playback is active — flips the layout to watch state. */ readonly playbackActive = input(false); diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts index 370db6310..de013e077 100644 --- a/libs/ui/components/src/lib/season-container/episode-download.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-download.util.ts @@ -1,4 +1,13 @@ -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + DownloadMetadataSnapshot, + XtreamSerieEpisode, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import { + createMovieDownloadSnapshot, + createSeriesEpisodeDownloadSnapshot, + type DownloadMovieSnapshotInput, +} from '@iptvnator/portal/shared/util'; /** * Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the @@ -12,6 +21,9 @@ export interface XtreamEpisodeDownloadContext { password?: string; } +/** Provider-neutral metadata already loaded by the detail host. */ +export type SeasonContainerDownloadMetadataContext = DownloadMovieSnapshotInput; + export interface XtreamEpisodeDownloadRequest { playlistId: string; xtreamId: number; @@ -22,6 +34,42 @@ export interface XtreamEpisodeDownloadRequest { seriesXtreamId: number; seasonNumber: number; episodeNumber: number; + metadataSnapshot?: DownloadMetadataSnapshot; +} + +function rowCoordinate(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function buildMetadataSnapshot( + metadata: SeasonContainerDownloadMetadataContext, + episode: XtreamSerieEpisode, + seasonNumber: unknown, + episodeNumber: unknown +): DownloadMetadataSnapshot { + const normalizedSeason = rowCoordinate(seasonNumber); + const normalizedEpisode = rowCoordinate(episodeNumber); + if (normalizedSeason === undefined || normalizedEpisode === undefined) { + return { + ...createMovieDownloadSnapshot(metadata), + mediaKind: 'series', + }; + } + + const info = Array.isArray(episode.info) + ? undefined + : (episode.info as XtreamSerieEpisodeInfo); + return createSeriesEpisodeDownloadSnapshot({ + ...metadata, + episode: { + seasonNumber: normalizedSeason, + episodeNumber: normalizedEpisode, + title: episode.title, + plot: info?.plot, + stillUrl: info?.movie_image, + }, + }); } export function hashString(str: string): number { @@ -75,6 +123,7 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesTitle: string; fallbackSeasonKey: string | undefined; posterUrl?: string; + metadataContext?: SeasonContainerDownloadMetadataContext; }): XtreamEpisodeDownloadRequest { const { episode, context, playlistId, seriesId, seriesTitle } = options; const serverUrl = context.serverUrl?.replace(/\/$/, '') || ''; @@ -84,7 +133,9 @@ export function buildXtreamEpisodeDownloadRequest(options: { const seasonNumber = episode.season || Number(options.fallbackSeasonKey) || 1; const episodeNumber = episode.episode_num || 1; - const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart( + const title = `${seriesTitle || 'Series'} - S${String( + seasonNumber + ).padStart( 2, '0' )}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`; @@ -99,5 +150,15 @@ export function buildXtreamEpisodeDownloadRequest(options: { seriesXtreamId: seriesId, seasonNumber, episodeNumber, + ...(options.metadataContext + ? { + metadataSnapshot: buildMetadataSnapshot( + options.metadataContext, + episode, + seasonNumber, + episodeNumber + ), + } + : {}), }; } 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 839ac554e..c363f2852 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 @@ -66,10 +66,90 @@ describe('episode-download.util', () => { fallbackSeasonKey: '1', }); - expect(request.url).toBe('http://host/series/u/p/55.mkv'); - expect(request.title).toBe('Show - S02E03 - The One'); - expect(request.seasonNumber).toBe(2); - expect(request.seriesXtreamId).toBe(900); + expect(request).toEqual({ + playlistId: 'pl-1', + xtreamId: 55, + contentType: 'episode', + title: 'Show - S02E03 - The One', + url: 'http://host/series/u/p/55.mkv', + posterUrl: undefined, + seriesXtreamId: 900, + seasonNumber: 2, + episodeNumber: 3, + }); + }); + + it('adds the loaded series and episode metadata when provided', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ + id: '55', + episode_num: 3, + season: 2, + title: 'The One', + info: { + plot: 'Episode plot', + movie_image: 'https://images.test/stills/one.jpg', + }, + }), + context: { serverUrl: 'http://host' }, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: '1', + metadataContext: { + language: 'de', + title: 'Show', + plot: 'Series plot', + genres: ['Drama'], + posterUrl: 'poster.jpg', + providerCategoryId: '7', + cast: [{ name: 'Actor' }], + }, + }); + + expect(request.metadataSnapshot).toEqual( + expect.objectContaining({ + language: 'de', + mediaKind: 'series', + title: 'Show', + plot: 'Series plot', + genres: ['Drama'], + providerCategoryId: '7', + cast: [{ name: 'Actor' }], + episode: { + seasonNumber: 2, + episodeNumber: 3, + title: 'The One', + plot: 'Episode plot', + stillUrl: 'https://images.test/stills/one.jpg', + }, + }) + ); + }); + + it('creates a valid sparse snapshot without provider enrichment', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ season: 1, info: [] }), + context: {}, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: undefined, + metadataContext: { language: 'en', title: 'Show' }, + }); + + expect(request.metadataSnapshot).toEqual( + expect.objectContaining({ + language: 'en', + mediaKind: 'series', + title: 'Show', + episode: { + seasonNumber: 1, + episodeNumber: 1, + title: 'Ep', + }, + }) + ); }); }); 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 0cb1f81ba..4285c4ca1 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 @@ -155,7 +155,7 @@ }}
- @if (isElectron()) { + @if (isElectron() && downloadsEnabled()) { @if (isEpisodeDownloaded(episode)) { - } @else if ( - isEpisodePaused(episode) - ) { + } @else if (isEpisodePaused(episode)) { - @if (!isExternalStopAction()) { + @if (externalPrimaryButtonState() === 'idle' && !isDownloaded()) { + @if (externalPrimaryButtonState() === 'idle') { + + } } @else if (isPausedDownload()) {