feat(downloads): surface missing files for recovery

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 596f5a8d4d
commit 8a5b29ea4d
5 files changed
+344 -115

No files matched your search

@@ -17,6 +17,7 @@
[class.download-queue__item--pending]="
isPending(row.item.id)
"
[class.download-queue__item--missing]="isMissingFile(row)"
[attr.aria-busy]="isPending(row.item.id) ? 'true' : null"
[attr.data-test-id]="'download-queue-item-' + row.item.id"
role="listitem"
@@ -90,17 +91,13 @@
aria-atomic="true"
[class]="
'download-queue__status download-queue__status--' +
row.item.status
statusClass(row)
"
>
<mat-icon aria-hidden="true">
{{ statusIcon(row.item) }}
{{ statusIcon(row) }}
</mat-icon>
{{
'DOWNLOADS.STATUS.' +
row.item.status.toUpperCase()
| translate
}}
{{ statusKey(row) | translate }}
</span>
<span class="download-queue__bytes">
{{ formatBytes(row.item.bytesDownloaded) }}
@@ -109,11 +106,6 @@
{{ formatBytes(row.item.totalBytes) }}
}
</span>
@if (row.sourceName.trim()) {
<span class="download-queue__source">
{{ row.sourceName.trim() }}
</span>
}
@if (row.item.errorMessage?.trim(); as error) {
<span class="download-queue__error">
{{ error }}
@@ -135,95 +127,121 @@
</div>
<div class="download-queue__actions">
@switch (row.item.status) {
@case ('queued') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('downloading') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('paused') {
<button
type="button"
mat-icon-button
data-test-action="resume"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
(click)="emitAction('resume', row.item)"
>
<mat-icon>play_arrow</mat-icon>
</button>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('failed') {
<ng-container
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('canceled') {
<ng-container
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
@if (isMissingFile(row)) {
<button
type="button"
mat-stroked-button
class="download-queue__recover"
data-test-action="redownload"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.DOWNLOAD_AGAIN'
| translate: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.DOWNLOAD_AGAIN'
| translate: { title: row.item.title }
"
(click)="emitAction('redownload', row.item)"
>
<mat-icon>refresh</mat-icon>
<span>
{{ 'DOWNLOADS.DOWNLOAD_AGAIN' | translate }}
</span>
</button>
} @else {
@switch (row.item.status) {
@case ('queued') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('downloading') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('paused') {
<button
type="button"
mat-icon-button
data-test-action="resume"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
(click)="
emitAction('resume', row.item)
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('failed') {
<ng-container
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('canceled') {
<ng-container
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
}
}
@@ -245,6 +263,9 @@
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #itemMenu="matMenu" xPosition="before">
<app-download-source-menu-header
[sourceName]="row.sourceName"
/>
<button
type="button"
mat-menu-item
@@ -269,6 +290,29 @@
}}
</span>
</button>
@if (isMissingFile(row)) {
<button
type="button"
mat-menu-item
data-test-action="remove"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.REMOVE'
| translate
: { title: row.item.title }
"
(click)="emitAction('remove', row.item)"
>
<mat-icon>delete_outline</mat-icon>
<span>
{{
'DOWNLOADS.ARIA.REMOVE'
| translate
: { title: row.item.title }
}}
</span>
</button>
}
</mat-menu>
</div>
</article>
@@ -68,6 +68,18 @@
opacity: 0.68;
}
}
&--missing {
background: color-mix(
in srgb,
var(--mat-sys-tertiary-container) 18%,
var(--mat-sys-surface-container-low)
);
.download-queue__artwork {
filter: saturate(0.42);
}
}
}
&__artwork {
@@ -206,6 +218,11 @@
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
&--missing {
background: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
}
&__bytes {
@@ -215,12 +232,6 @@
font-variant-numeric: tabular-nums;
}
&__source {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__error {
min-width: 0;
max-width: 100%;
@@ -254,6 +265,14 @@
}
}
&__recover {
white-space: nowrap;
mat-icon {
margin-inline-end: 6px;
}
}
button:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
@@ -23,7 +23,9 @@ const TEST_TRANSLATIONS = {
PAUSED: 'Paused',
FAILED: 'Failed',
CANCELED: 'Canceled',
FILE_MISSING: 'File missing',
},
DOWNLOAD_AGAIN: 'Download again',
ARIA: {
OPEN: 'Open {{title}}',
POSTER_UNAVAILABLE: 'Artwork unavailable for {{title}}',
@@ -35,6 +37,12 @@ const TEST_TRANSLATIONS = {
RETRY: 'Retry {{title}}',
MORE_ACTIONS: 'More actions for {{title}}',
COPY_URL: 'Copy URL for {{title}}',
DOWNLOAD_AGAIN: 'Download {{title}} again',
},
},
PORTALS: {
MULTI_SOURCE: {
SOURCE: 'Source',
},
},
};
@@ -63,6 +71,7 @@ function createRow(
overrides: Partial<DownloadListItemViewModel> = {}
): DownloadListItemViewModel {
return {
attentionReason: 'transfer',
item: createItem(id, status),
episodeLabel: '',
seriesTitle: '',
@@ -192,6 +201,97 @@ describe('DownloadQueueComponent', () => {
}
);
it('renders a missing completed file as recoverable attention', async () => {
const viewModel = createRow(8, 'completed', {
attentionReason: 'file-missing',
item: createItem(8, 'completed', {
bytesDownloaded: 2 * 1024 ** 2,
fileAvailability: 'missing',
filePath: '/downloads/missing.mp4',
}),
sourceName: 'Alpha Source',
});
const emitted: DownloadItemAction[] = [];
fixture.componentInstance.itemAction.subscribe((action) =>
emitted.push(action)
);
render([], [viewModel]);
const host = row(8);
const status = host.querySelector<HTMLElement>(
'.download-queue__status'
);
const renderedActions = Array.from(
host.querySelectorAll<HTMLButtonElement>(
'[data-test-action]:not([data-test-action="more"])'
)
).map((button) => button.dataset['testAction']);
expect(status?.textContent).toContain('File missing');
expect(status?.querySelector('mat-icon')?.textContent?.trim()).toBe(
'file_off'
);
expect(renderedActions).toEqual(['redownload']);
expect(host.querySelector('[data-test-action="play"]')).toBeNull();
expect(host.querySelector('[data-test-action="reveal"]')).toBeNull();
expect(host.querySelector('.download-queue__source')).toBeNull();
actionButton(host, 'redownload').click();
expect(emitted).toEqual([
{ type: 'redownload', item: viewModel.item },
]);
actionButton(host, 'more').click();
fixture.detectChanges();
await fixture.whenStable();
const menu = overlayContainer.getContainerElement();
const header = menu.querySelector<HTMLElement>(
'.download-source-menu-header'
);
const copy = actionButton(menu, 'copy-url');
const remove = actionButton(menu, 'remove');
expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'Source Alpha Source'
);
expect(header?.querySelector('strong')?.textContent).toContain(
'Alpha Source'
);
expect(
header &&
header.compareDocumentPosition(copy) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
copy.compareDocumentPosition(remove) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
it('disables Download again while pending and omits an empty source header', async () => {
const viewModel = createRow(9, 'completed', {
attentionReason: 'file-missing',
item: createItem(9, 'completed', {
fileAvailability: 'missing',
filePath: '/downloads/missing.mp4',
}),
sourceName: ' ',
});
render([], [viewModel], new Set([9]));
expect(actionButton(row(9), 'redownload').disabled).toBe(true);
fixture.componentRef.setInput('pendingIds', new Set());
fixture.detectChanges();
actionButton(row(9), 'more').click();
fixture.detectChanges();
await fixture.whenStable();
expect(
overlayContainer
.getContainerElement()
.querySelector('.download-source-menu-header')
).toBeNull();
});
it.each([
['queued', ['pause', 'cancel']],
['downloading', ['pause', 'cancel']],
@@ -413,7 +513,7 @@ describe('DownloadQueueComponent', () => {
expect(formatDownloadBytes(bytes)).toBe(expected);
});
it('renders downloaded/total bytes, episode/source/error metadata, and omits empty sources', () => {
it('renders downloaded/total bytes and episode/error metadata without a permanent source', () => {
const longError =
'https://downloads.example.test/a/very/long/unbroken/path/that/must/remain/readable/error-code-connection-reset';
const detailed = createRow(21, 'failed', {
@@ -447,11 +547,7 @@ describe('DownloadQueueComponent', () => {
.querySelector('.download-queue__episode')
?.textContent?.trim()
).toBe('S02E04');
expect(
row(21)
.querySelector('.download-queue__source')
?.textContent?.trim()
).toBe('Alpha Source');
expect(row(21).querySelector('.download-queue__source')).toBeNull();
const error = row(21).querySelector<HTMLElement>(
'.download-queue__error'
);
@@ -19,6 +19,7 @@ import type {
DownloadItemActionType,
} from './download-actions';
import type { DownloadListItemViewModel } from './download-manager.viewmodel';
import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component';
type DownloadProgressMode = 'determinate' | 'indeterminate';
@@ -71,6 +72,7 @@ export function formatDownloadBytes(bytes: number | undefined): string {
MatTooltip,
NgTemplateOutlet,
TranslatePipe,
DownloadSourceMenuHeaderComponent,
],
templateUrl: './download-queue.component.html',
styleUrl: './download-queue.component.scss',
@@ -121,8 +123,24 @@ export class DownloadQueueComponent {
return this.pendingIds().has(id);
}
statusIcon(item: DownloadItem): string {
return STATUS_ICONS[item.status];
isMissingFile(row: DownloadListItemViewModel): boolean {
return row.attentionReason === 'file-missing';
}
statusIcon(row: DownloadListItemViewModel): string {
return this.isMissingFile(row)
? 'file_off'
: STATUS_ICONS[row.item.status];
}
statusKey(row: DownloadListItemViewModel): string {
return this.isMissingFile(row)
? 'DOWNLOADS.STATUS.FILE_MISSING'
: `DOWNLOADS.STATUS.${row.item.status.toUpperCase()}`;
}
statusClass(row: DownloadListItemViewModel): string {
return this.isMissingFile(row) ? 'missing' : row.item.status;
}
hasPoster(item: DownloadItem): boolean {
@@ -0,0 +1,52 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-download-source-menu-header',
imports: [MatTooltip, TranslatePipe],
template: `
@if (sourceName().trim(); as source) {
<div class="download-source-menu-header">
<span>
{{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }}
</span>
<strong [matTooltip]="source">{{ source }}</strong>
</div>
}
`,
styles: `
:host {
display: block;
}
.download-source-menu-header {
display: grid;
gap: 2px;
max-width: 280px;
padding: 10px 16px 8px;
color: var(--mat-sys-on-surface-variant);
span {
font: var(--mat-sys-label-small);
letter-spacing: 0.03em;
}
strong {
overflow: hidden;
color: var(--mat-sys-on-surface);
font: var(--mat-sys-label-large);
text-overflow: ellipsis;
white-space: nowrap;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadSourceMenuHeaderComponent {
readonly sourceName = input('');
}