265 lines
10 KiB
Svelte
265 lines
10 KiB
Svelte
<script lang="ts">
|
||
import { plural } from '#lib/utils/plural.js';
|
||
import { Download as DownloadIcon } from 'lucide-svelte';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import {
|
||
abandonDownload,
|
||
createDownloadsQuery,
|
||
importAnyway,
|
||
refuseDownload,
|
||
retryImport
|
||
} from '#lib/api/wanted.js';
|
||
import { errMessage } from '#lib/api/errors.js';
|
||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||
import type { Download, DownloadCondition, ImportEvidence } from '#lib/api/types.js';
|
||
|
||
// The Activity tab (M502 #5522): every download Minstrel took, running
|
||
// ones first. A finished download is checked against MusicBrainz before it
|
||
// is filed; one the check could not settle waits here for a person, who
|
||
// imports it anyway or refuses it. A refused release is set aside (the
|
||
// Blocked tab) and its album searched for again. A download that fails, or
|
||
// that nobody shares, is retried and then given up the same way, held back
|
||
// for a week; an imported one seeds, then is removed (#5523).
|
||
|
||
const client = useQueryClient();
|
||
const downloadsStore = createDownloadsQuery();
|
||
const downloads = $derived($downloadsStore);
|
||
const items = $derived((downloads.data?.pages ?? []).flatMap((p) => p.items) as Download[]);
|
||
const clientsDown = $derived(downloads.data?.pages[0]?.clients_down ?? []);
|
||
|
||
let busy = $state<string | null>(null);
|
||
|
||
async function act(d: Download, run: (id: string) => Promise<void>, said: string) {
|
||
busy = d.id;
|
||
try {
|
||
await run(d.id);
|
||
pushToast(said);
|
||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||
} catch (e) {
|
||
pushToast(errMessage(e), 'error');
|
||
} finally {
|
||
busy = null;
|
||
}
|
||
}
|
||
|
||
const onImport = (d: Download) =>
|
||
act(d, importAnyway, `${d.name} will be imported within the minute.`);
|
||
const onRetry = (d: Download) => act(d, retryImport, `${d.name} will be tried again within the minute.`);
|
||
const onRefuse = (d: Download) =>
|
||
act(d, refuseDownload, `Refused ${d.name}. It's set aside and the album will be searched for again.`);
|
||
const onAbandon = (d: Download) =>
|
||
act(d, abandonDownload, `Gave up on ${d.name}. It's set aside and the album will be searched for again.`);
|
||
|
||
// A download still downloading, or waiting to be: one a person may give up.
|
||
const running = (d: Download) =>
|
||
!d.ended_at && (d.import_state === '' || d.import_state === 'downloading' || d.import_state === 'waiting');
|
||
|
||
// How a download that ended without an import ended.
|
||
const endings: Record<string, string> = {
|
||
stalled: 'Given up',
|
||
failed: 'Failed',
|
||
abandoned: 'Given up by you',
|
||
gone: 'Left the client',
|
||
superseded: 'Replaced'
|
||
};
|
||
|
||
// The state of a download as a short label, and whether it needs a person.
|
||
function label(d: Download): string {
|
||
if (d.ended_at && endings[d.end_reason]) return endings[d.end_reason];
|
||
switch (d.import_state) {
|
||
case 'downloading':
|
||
case '':
|
||
return d.ended_at ? 'Ended' : 'Downloading';
|
||
case 'waiting':
|
||
return 'Waiting';
|
||
case 'importing':
|
||
return 'Importing';
|
||
case 'imported':
|
||
return 'Imported';
|
||
case 'refused':
|
||
return 'Refused';
|
||
case 'review':
|
||
return 'Needs you';
|
||
case 'held':
|
||
return 'Already held';
|
||
case 'failed':
|
||
return 'Import failed';
|
||
}
|
||
return d.import_state;
|
||
}
|
||
|
||
const needsYou = (d: Download) => !d.ended_at && (d.import_state === 'review' || d.import_state === 'failed');
|
||
|
||
function badge(d: Download): string {
|
||
if (needsYou(d)) return 'border-accent text-text-primary';
|
||
if (d.import_state === 'refused' || (d.ended_at && endings[d.end_reason])) return 'border-error-fg text-error-fg';
|
||
return 'border-border text-text-secondary';
|
||
}
|
||
|
||
// What the check counted, in a line.
|
||
function counted(e: ImportEvidence): string {
|
||
const parts = [`${e.files} files against ${e.tracks} tracks`];
|
||
if (e.fingerprinted) parts.push(`${e.recordings} identified by AcoustID`);
|
||
else parts.push('no AcoustID');
|
||
parts.push(`${e.lengths} lengths agree`, `${e.titles} titles agree`);
|
||
if (e.album_tag) parts.push(`album tag “${e.album_tag}”`);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
// What the lifecycle sees wrong with a running download, and what it did.
|
||
function condition(c: DownloadCondition): string {
|
||
let said = c.detail;
|
||
if (c.trigger === 'no_peers' || c.trigger === 'no_metadata') {
|
||
said += c.retries
|
||
? ` Asked for again ${c.retries} of 3 times; given up after that.`
|
||
: ' It will be asked for again three times, then given up.';
|
||
}
|
||
return said;
|
||
}
|
||
|
||
// What ended a download that was not imported, in the lifecycle's words.
|
||
const ending = (d: Download) => (d.ended_at && endings[d.end_reason] ? d.end_detail : '');
|
||
|
||
function seeded(d: Download): string | null {
|
||
if (d.seed_released_at) return `Seeded, then removed from the download client ${relativeTime(d.seed_released_at)}.`;
|
||
if (d.seeding) return `Seeding: share ratio ${d.ratio.toFixed(2)}.`;
|
||
return null;
|
||
}
|
||
|
||
function when(d: Download): string {
|
||
const who = d.origin === 'operator' ? 'by you' : 'by the schedule';
|
||
let said = `Taken ${who} ${relativeTime(d.grabbed_at)}`;
|
||
if (d.indexer) said += ` from ${d.indexer}`;
|
||
if (d.imported_at) said += ` · imported ${relativeTime(d.imported_at)}`;
|
||
else if (d.ended_at) said += ` · ended ${relativeTime(d.ended_at)}`;
|
||
return said;
|
||
}
|
||
|
||
function filed(d: Download): string | null {
|
||
if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
|
||
let said = plural(d.imported_files, 'track filed', 'tracks filed');
|
||
if (d.skipped) said += `, ${d.skipped} already held`;
|
||
if (d.destination) said += ` into ${d.destination}`;
|
||
return said;
|
||
}
|
||
|
||
const button =
|
||
'rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||
</script>
|
||
|
||
{#each clientsDown as c (c.client)}
|
||
<p
|
||
class="mb-3 rounded-lg border border-error-fg bg-surface p-3 text-sm text-text-primary"
|
||
role="status"
|
||
data-testid="client-down"
|
||
>
|
||
Can't reach the download client {c.client} since {relativeTime(c.since)}: {c.detail}. Its
|
||
downloads are left as they are until it answers.
|
||
</p>
|
||
{/each}
|
||
|
||
{#if downloads.isPending}
|
||
<p class="text-text-secondary">Reading the downloads…</p>
|
||
{:else if downloads.isError && !downloads.isFetchNextPageError}
|
||
<p class="text-error-fg">Couldn't load the downloads.</p>
|
||
{:else if items.length === 0}
|
||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||
<DownloadIcon size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||
<p class="mt-3 text-text-primary">Nothing has been downloaded yet.</p>
|
||
<p class="mt-1 text-sm text-text-secondary">
|
||
A release the search takes, or one you take from a want's Releases, shows here while it
|
||
downloads and once it's imported.
|
||
</p>
|
||
</div>
|
||
{:else}
|
||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||
{#each items as d (d.id)}
|
||
<li class="space-y-1.5 p-3" data-testid="download-row">
|
||
<div class="flex flex-wrap items-start gap-x-3 gap-y-1">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="truncate text-sm font-medium text-text-primary">
|
||
{d.want_artist ? `${d.want_artist} – ${d.want_title}` : d.name}
|
||
</div>
|
||
<div class="truncate text-xs text-text-secondary" title={d.name}>
|
||
{d.name}{#if d.quality}<span class="text-text-muted"> · {d.quality}</span>{/if}
|
||
</div>
|
||
</div>
|
||
<span class="shrink-0 rounded-full border px-2 py-0.5 text-xs {badge(d)}" data-testid="download-state">
|
||
{label(d)}
|
||
</span>
|
||
</div>
|
||
|
||
{#if !d.ended_at && (d.import_state === 'downloading' || d.import_state === '')}
|
||
<div
|
||
class="h-1.5 overflow-hidden rounded-full bg-surface-hover"
|
||
role="progressbar"
|
||
aria-label="Downloaded"
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={Math.floor(d.progress * 100)}
|
||
>
|
||
<div class="h-full bg-accent" style="width: {Math.floor(d.progress * 100)}%"></div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if running(d) && d.condition}
|
||
<p class="text-xs text-text-primary" data-testid="download-condition">{condition(d.condition)}</p>
|
||
{/if}
|
||
{#if ending(d)}
|
||
<p class="text-xs text-text-secondary" data-testid="download-ending">{ending(d)}</p>
|
||
{/if}
|
||
{#if d.import_reason}
|
||
<p class="text-xs {needsYou(d) ? 'text-text-primary' : 'text-text-secondary'}" data-testid="download-reason">
|
||
{d.import_reason}
|
||
</p>
|
||
{/if}
|
||
{#if d.evidence}
|
||
<p class="text-xs text-text-muted">{counted(d.evidence)}</p>
|
||
{/if}
|
||
{#if filed(d)}
|
||
<p class="truncate text-xs text-text-secondary" title={d.destination}>{filed(d)}</p>
|
||
{/if}
|
||
{#if seeded(d)}
|
||
<p class="text-xs text-text-muted" data-testid="download-seeding">{seeded(d)}</p>
|
||
{/if}
|
||
{#if d.rehomed_from}
|
||
<p class="text-xs text-text-muted">Taken for {d.rehomed_from}; it turned out to be this album.</p>
|
||
{/if}
|
||
<p class="text-xs text-text-muted">{when(d)}</p>
|
||
|
||
{#if running(d)}
|
||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onAbandon(d)}>
|
||
Give up
|
||
</button>
|
||
</div>
|
||
{:else if needsYou(d)}
|
||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||
{#if d.import_state === 'review'}
|
||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onImport(d)}>
|
||
Import anyway
|
||
</button>
|
||
{:else}
|
||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRetry(d)}>
|
||
Try again
|
||
</button>
|
||
{/if}
|
||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRefuse(d)}>
|
||
Refuse
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
|
||
<ListContinuation
|
||
hasMore={!!downloads.hasNextPage}
|
||
loading={downloads.isFetchingNextPage}
|
||
failed={downloads.isFetchNextPageError}
|
||
onMore={() => downloads.fetchNextPage()}
|
||
/>
|
||
{/if}
|