feat(web/m7-381): admin overview Library Scan section + manual trigger

Adds getScanStatus/triggerScan helpers, createScanStatusQuery factory
(3s poll), qk.scanStatus(), and a Library Scan section on the admin
overview page with per-stage tallies and inline Run scan button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 20:16:29 -04:00
parent 3d0e213081
commit 05bc2628a3
4 changed files with 201 additions and 1 deletions
+118 -1
View File
@@ -6,12 +6,14 @@
createAdminRequestsQuery,
createLidarrConfigQuery,
createAdminQuarantineQuery,
createScanStatusQuery,
approveRequest,
rejectRequest,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr,
refetchMissingCovers
refetchMissingCovers,
triggerScan
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
@@ -178,6 +180,41 @@
}
}
// ---- Library scan ----
const scanStatusStore = $derived(createScanStatusQuery());
const scanStatusQ = $derived($scanStatusStore);
const scan = $derived(scanStatusQ.data);
const scanInFlight = $derived(scan?.in_flight === true);
let triggering = $state(false);
let triggerResult = $state<string | null>(null);
async function onTriggerScan() {
if (triggering || scanInFlight) return;
triggering = true;
triggerResult = null;
try {
await triggerScan();
triggerResult = 'Scan started.';
await client.invalidateQueries({ queryKey: qk.scanStatus() });
} catch (e) {
const code = (e as { code?: string; status?: number })?.code ?? 'unknown';
const status = (e as { status?: number })?.status;
if (status === 409) {
triggerResult = 'A scan is already running.';
} else {
triggerResult = `Failed: ${code}`;
}
} finally {
triggering = false;
}
}
function formatTime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString();
}
// ---- Cover art bulk refetch ----
let bulkBusy = $state(false);
let bulkResult = $state<string | null>(null);
@@ -276,6 +313,86 @@
{/if}
</section>
<!-- Library scan -->
<section class="rounded border border-border bg-surface p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-display text-lg font-medium">Library scan</h2>
<p class="mt-1 text-sm text-text-secondary">
Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers.
</p>
</div>
<button
type="button"
onclick={onTriggerScan}
disabled={triggering || scanInFlight}
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50"
>
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
</button>
</div>
{#if !scan || !scan.id}
<p class="mt-3 text-sm text-text-muted">No scan has run yet.</p>
{:else}
<div class="mt-3 grid gap-3 sm:grid-cols-3">
<!-- Library stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
{#if scan.library}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Scanned</dt><dd>{scan.library.scanned}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Added</dt><dd>{scan.library.added}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Updated</dt><dd>{scan.library.updated}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.library.skipped}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Errored</dt><dd>{scan.library.errored}</dd></div>
</dl>
{:else}
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
{/if}
</div>
<!-- MBID backfill stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
{#if scan.mbid_backfill}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.mbid_backfill.processed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Healed</dt><dd>{scan.mbid_backfill.healed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.mbid_backfill.skipped}</dd></div>
</dl>
{:else}
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
{/if}
</div>
<!-- Cover enrichment stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
{#if scan.cover_enrich}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.cover_enrich.processed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.cover_enrich.succeeded}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.cover_enrich.failed}</dd></div>
</dl>
{:else}
<p class="mt-2 text-sm text-text-muted">{scanInFlight ? 'Pending…' : 'Skipped.'}</p>
{/if}
</div>
</div>
<div class="mt-3 text-xs text-text-muted">
Started {formatTime(scan.started_at)}
{#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if}
{#if scan.error_message}· <span class="text-oxblood">Error: {scan.error_message}</span>{/if}
</div>
{/if}
{#if triggerResult}
<p class="mt-2 text-sm">{triggerResult}</p>
{/if}
</section>
<!-- Cover art bulk refetch -->
<section class="rounded border border-border bg-surface p-4">
<h2 class="font-display text-lg font-medium">Cover art</h2>