feat(downloads): finish manager MVP

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent eb55c17a05
commit bcc2e36a05
48 files changed
+4145 -1844

No files matched your search

+61 -8
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
@@ -30,10 +35,48 @@ The download manager is a desktop-only feature that layers a curated queue, prog
## 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; only `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. All surfaces
use the existing `--app-*` and Material system tokens. 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()`).
## Global API surface
@@ -42,7 +85,11 @@ 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.
- 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
@@ -60,6 +107,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.
@@ -183,13 +183,13 @@ render:
```html
@if (activeDownloadsCount() > 0) {
<span
class="download-count-badge"
data-test-id="global-download-count"
aria-hidden="true"
>
{{ activeDownloadsCount() }}
</span>
<span
class="download-count-badge"
data-test-id="global-download-count"
aria-hidden="true"
>
{{ activeDownloadsCount() }}
</span>
}
```
@@ -280,8 +280,9 @@ Add the same `test` executor/options as
Use a `download(overrides)` fixture and cover these exact assertions:
```ts
expect(buildDownloadManagerViewModel(input).active.map((x) => x.item.id))
.toEqual([oldestId, newestId]);
expect(
buildDownloadManagerViewModel(input).active.map((x) => x.item.id)
).toEqual([oldestId, newestId]);
expect(model.attention.map((x) => x.item.id)).toEqual([failedId, canceledId]);
expect(model.library.map((x) => x.key)).toEqual([
'series:playlist-a:42',
@@ -402,9 +403,7 @@ export function buildDownloadManagerViewModel(options: {
}): DownloadManagerViewModel;
export function normalizeDownloadFilter(value: string): DownloadFilterId {
return value === 'movie' ||
value === 'series' ||
value === 'in-progress'
return value === 'movie' || value === 'series' || value === 'in-progress'
? value
: 'all';
}
@@ -715,8 +714,10 @@ Expected: FAIL because the component is absent.
```ts
export class DownloadQueueComponent {
readonly activeItems = input.required<readonly DownloadListItemViewModel[]>();
readonly attentionItems = input.required<readonly DownloadListItemViewModel[]>();
readonly activeItems =
input.required<readonly DownloadListItemViewModel[]>();
readonly attentionItems =
input.required<readonly DownloadListItemViewModel[]>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<DownloadItemAction>();
readonly openRequested = output<DownloadItem>();
@@ -971,10 +972,10 @@ The rendered hierarchy is:
<div>
<h1 id="downloads-title">{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (model().activeCount > 0) {
<span data-test-id="downloads-active-count">
{{ 'DOWNLOADS.ACTIVE_COUNT' | translate:
{ count: model().activeCount } }}
</span>
<span data-test-id="downloads-active-count">
{{ 'DOWNLOADS.ACTIVE_COUNT' | translate: { count:
model().activeCount } }}
</span>
}
</div>
<div class="downloads__header-actions">
@@ -985,14 +986,14 @@ The rendered hierarchy is:
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
@if (model().hasClearable) {
<button mat-stroked-button (click)="clearFinished()">
{{ 'DOWNLOADS.CLEAR_FINISHED' | translate }}
</button>
<button mat-stroked-button (click)="clearFinished()">
{{ 'DOWNLOADS.CLEAR_FINISHED' | translate }}
</button>
}
</div>
<div data-test-id="downloads-tracked-bytes">
{{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} ·
{{ formatBytes(model().trackedBytes) }}
{{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} · {{
formatBytes(model().trackedBytes) }}
</div>
</header>
<div
@@ -1001,19 +1002,19 @@ The rendered hierarchy is:
[attr.aria-label]="'DOWNLOADS.FILTER.LABEL' | translate"
>
@for (category of categories(); track category.category_id) {
<button
type="button"
[class.is-selected]="
<button
type="button"
[class.is-selected]="
selectedCategoryId() === category.category_id
"
[attr.aria-pressed]="
[attr.aria-pressed]="
selectedCategoryId() === category.category_id
"
(click)="setFilter(category.category_id)"
>
{{ category.category_name }}
<span>{{ category.count }}</span>
</button>
(click)="setFilter(category.category_id)"
>
{{ category.category_name }}
<span>{{ category.count }}</span>
</button>
}
</div>
<main class="downloads__content">
@@ -1046,32 +1047,32 @@ Add consistent keys under `DOWNLOADS` in every locale:
```json
{
"ACTIVE_COUNT": "{{count}} active",
"TRACKED_DOWNLOADS": "Tracked downloads",
"CLEAR_FINISHED": "Clear finished",
"DOWNLOADING_NOW": "Downloading now",
"NEEDS_ATTENTION": "Needs attention",
"READY_TO_WATCH": "Ready to watch",
"OFFLINE_BADGE": "Offline",
"FILTER": {
"ALL": "All",
"MOVIES": "Movies",
"SERIES": "Series",
"IN_PROGRESS": "In progress"
},
"REMOVE_FROM_MANAGER": "Remove from manager",
"REMOVE_COMPLETED_DIALOG": {
"TITLE": "Remove from manager?",
"MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk."
},
"REMOVE_PARTIAL_DIALOG": {
"TITLE": "Remove partial download?",
"MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed."
},
"CLEAR_FINISHED_DIALOG": {
"TITLE": "Clear finished downloads?",
"MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted."
}
"ACTIVE_COUNT": "{{count}} active",
"TRACKED_DOWNLOADS": "Tracked downloads",
"CLEAR_FINISHED": "Clear finished",
"DOWNLOADING_NOW": "Downloading now",
"NEEDS_ATTENTION": "Needs attention",
"READY_TO_WATCH": "Ready to watch",
"OFFLINE_BADGE": "Offline",
"FILTER": {
"ALL": "All",
"MOVIES": "Movies",
"SERIES": "Series",
"IN_PROGRESS": "In progress"
},
"REMOVE_FROM_MANAGER": "Remove from manager",
"REMOVE_COMPLETED_DIALOG": {
"TITLE": "Remove from manager?",
"MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk."
},
"REMOVE_PARTIAL_DIALOG": {
"TITLE": "Remove partial download?",
"MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed."
},
"CLEAR_FINISHED_DIALOG": {
"TITLE": "Clear finished downloads?",
"MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted."
}
}
```
@@ -121,12 +121,12 @@ 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 |
| 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
@@ -162,7 +162,7 @@ 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
`- 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
@@ -234,13 +234,13 @@ Every queue row contains:
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 |
| 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
@@ -317,15 +317,15 @@ explicit delete and recovery semantics.
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` |
| 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, and the shared `content-grid` Sass mixin.