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

@@ -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.