mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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
This commit is contained in:
1 parent
46c58f4f57
commit
760099358b
188 files changed
+31245
-2281
No files matched your search
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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<DownloadItem>();
|
||||
|
||||
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
|
||||
<app-download-library
|
||||
[entities]="model().library"
|
||||
[availablePlaylistIds]="availablePlaylistIds()"
|
||||
[pendingIds]="pendingIds()"
|
||||
(itemAction)="runAction($event)"
|
||||
(openRequested)="openInLibrary($event)"
|
||||
(episodesOpened)="openDownloadedSeries($event)"
|
||||
/>
|
||||
```
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
```
|
||||
@@ -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<Stats, 'isFile' | 'isSymbolicLink'>;
|
||||
|
||||
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<DownloadAvailabilityRow, 'filePath' | 'status'>,
|
||||
lstat: DownloadLstat = lstatSync
|
||||
): ElectronDownloadFileAvailability {
|
||||
if (row.status !== 'completed') return 'not-applicable';
|
||||
return isAvailableDownloadFile(row.filePath, lstat)
|
||||
? 'available'
|
||||
: 'missing';
|
||||
}
|
||||
|
||||
export function decorateDownloadItem<T extends DownloadAvailabilityRow>(
|
||||
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<string, string> | 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<string, unknown>;
|
||||
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
|
||||
(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<ElectronBridgeDownloadRedownloadResult>;
|
||||
|
||||
// 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<void>
|
||||
```
|
||||
|
||||
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) {
|
||||
<div class="download-source-menu-header">
|
||||
<span>{{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }}</span>
|
||||
<strong [matTooltip]="source">{{ source }}</strong>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
// 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
|
||||
<app-download-source-menu-header [sourceName]="entity.sourceName" />
|
||||
```
|
||||
|
||||
Add a three-dot series action and Material menu containing the source header
|
||||
and:
|
||||
|
||||
```html
|
||||
<button mat-menu-item type="button" (click)="openEpisodes(entity)">
|
||||
<mat-icon aria-hidden="true">video_library</mat-icon>
|
||||
<span>{{ 'DOWNLOADS.OPEN_EPISODES' | translate }}</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
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> = {}
|
||||
): 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"
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user