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:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

+147 -10
View File
@@ -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:
+7 -5
View File
@@ -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.
+19
View File
@@ -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: