mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(downloads): surface missing files for recovery
This commit is contained in:
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('');
|
||||
}
|
||||
Reference in new issue
Block a user