mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
feat(downloads): finish manager MVP
This commit is contained in:
1 parent
eb55c17a05
commit
bcc2e36a05
48 files changed
+4145
-1844
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user