mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14: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
@@ -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