test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
The scan fingerprints only bytes it has not seen, so everything imported before fingerprinting existed, and any row derived by an older fingerprintVersion, needs a pass of its own. That pass is a background worker, not a stage in RunScan. RunScan runs at boot and then every 12h, and an in-flight scan older than an hour is reaped and a second started beside it. A stage would have to stop inside the hour: a few hundred decodes a run, so about a month for a 50k-track library. It would also hold the run in flight and answer manual rescans with 409 while it worked. FingerprintBackfillWorker runs once at start, then hourly. Nothing a pass does (error or panic) can stop the next tick. A pass walks tracks with no fingerprint or a stale version, skipping missing tracks, keyset-paged on id. The cursor is what lets a pass end: an inconclusive attempt writes no row, so a file that keeps timing out would otherwise be re-listed and retried forever. Two decodes at a time, deliberately: they compete with transcoding for CPU and with streaming for the mount. storeFingerprint is now one package function shared by the scan and the worker, and reports whether the attempt was fingerprinted, rejected, inconclusive or failed to store. Progress is a live gauge on the Admin scan card, served by GET /api/admin/library/fingerprints: fingerprinted / rejected / pending of total, with missing tracks excluded so it can reach the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
585 lines
24 KiB
Svelte
585 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '$lib/branding';
|
|
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
|
import StageBadge from '$lib/components/StageBadge.svelte';
|
|
import { stageState } from './stage-state';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createAdminRequestsQuery,
|
|
createLidarrConfigQuery,
|
|
createAdminQuarantineQuery,
|
|
createScanStatusQuery,
|
|
createCoverageQuery,
|
|
createFingerprintCoverageQuery,
|
|
approveRequest,
|
|
rejectRequest,
|
|
resolveQuarantine,
|
|
deleteQuarantineFile,
|
|
deleteQuarantineViaLidarr,
|
|
refetchMissingCovers,
|
|
triggerScan,
|
|
researchMissingArt
|
|
} from '$lib/api/admin';
|
|
import { qk } from '$lib/api/queries';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import { runMutation } from '$lib/util/runMutation';
|
|
import type {
|
|
AdminQuarantineRow,
|
|
LidarrRequest,
|
|
LidarrRequestKind,
|
|
LidarrQuarantineReason
|
|
} from '$lib/api/types';
|
|
|
|
// Top-of-admin overview. Shows the connection card + the most recent
|
|
// 5 of each actionable queue (requests, quarantine) with inline
|
|
// handlers for the common case. The dedicated pages keep the full
|
|
// modal flows (override, typed-confirm); the overview offers a faster
|
|
// path for the common-case actions, with a two-click confirm on
|
|
// destructive quarantine actions to avoid modal duplication.
|
|
|
|
const PREVIEW_LIMIT = 5;
|
|
|
|
const client = useQueryClient();
|
|
|
|
const requestsStore = createAdminRequestsQuery('pending');
|
|
const requests = $derived($requestsStore);
|
|
|
|
const configStore = createLidarrConfigQuery();
|
|
const config = $derived($configStore);
|
|
|
|
const quarantineStore = createAdminQuarantineQuery();
|
|
const quarantine = $derived($quarantineStore);
|
|
|
|
const pendingCount = $derived(requests.data?.length ?? 0);
|
|
const quarantineCount = $derived(quarantine.data?.length ?? 0);
|
|
const lidarrConnected = $derived(config.data?.enabled === true);
|
|
|
|
const requestPreview = $derived(
|
|
(requests.data ?? []).slice(0, PREVIEW_LIMIT)
|
|
);
|
|
const quarantinePreview = $derived(
|
|
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
|
|
);
|
|
|
|
// ---- Two-click destructive confirm for quarantine actions ----
|
|
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
|
|
// click within 3s fires the action; otherwise the key clears.
|
|
let confirmingKey = $state<string | null>(null);
|
|
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function confirmKey(trackID: string, action: 'delete-file' | 'delete-lidarr'): string {
|
|
return `${trackID}:${action}`;
|
|
}
|
|
function armConfirm(trackID: string, action: 'delete-file' | 'delete-lidarr') {
|
|
confirmingKey = confirmKey(trackID, action);
|
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
confirmTimer = setTimeout(() => { confirmingKey = null; }, 3000);
|
|
}
|
|
function clearConfirm() {
|
|
confirmingKey = null;
|
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
function fallbackIcon(kind: LidarrRequestKind) {
|
|
if (kind === 'artist') return Disc3;
|
|
if (kind === 'album') return Album;
|
|
return Music2;
|
|
}
|
|
function rowTitle(r: LidarrRequest): string {
|
|
if (r.kind === 'artist') return r.artist_name;
|
|
if (r.kind === 'album') return r.album_title ?? '—';
|
|
return r.track_title ?? '—';
|
|
}
|
|
function rowSubtitle(r: LidarrRequest): string {
|
|
if (r.kind === 'artist') return 'Artist';
|
|
if (r.kind === 'album') return `Album · ${r.artist_name}`;
|
|
return `Track · ${r.artist_name}`;
|
|
}
|
|
|
|
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
|
bad_rip: 'Bad rip',
|
|
wrong_file: 'Wrong file',
|
|
wrong_tags: 'Wrong tags',
|
|
duplicate: 'Duplicate',
|
|
other: 'Other'
|
|
};
|
|
|
|
// ---- Action handlers ----
|
|
async function invalidateRequests() {
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.adminRequests('pending') }),
|
|
client.invalidateQueries({ queryKey: qk.adminRequests('approved') }),
|
|
client.invalidateQueries({ queryKey: qk.adminRequests('rejected') }),
|
|
client.invalidateQueries({ queryKey: qk.adminRequests('completed') })
|
|
]);
|
|
}
|
|
|
|
async function onApprove(r: LidarrRequest) {
|
|
await runMutation(async () => {
|
|
await approveRequest(r.id);
|
|
await invalidateRequests();
|
|
});
|
|
}
|
|
async function onReject(r: LidarrRequest) {
|
|
await runMutation(async () => {
|
|
await rejectRequest(r.id);
|
|
await invalidateRequests();
|
|
});
|
|
}
|
|
|
|
async function invalidateQuarantine() {
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
|
|
client.invalidateQueries({ queryKey: qk.adminQuarantineActions() })
|
|
]);
|
|
}
|
|
|
|
async function onResolve(row: AdminQuarantineRow) {
|
|
await runMutation(async () => {
|
|
await resolveQuarantine(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
async function onDeleteFile(row: AdminQuarantineRow) {
|
|
const key = confirmKey(row.track_id, 'delete-file');
|
|
if (confirmingKey !== key) {
|
|
armConfirm(row.track_id, 'delete-file');
|
|
return;
|
|
}
|
|
clearConfirm();
|
|
await runMutation(async () => {
|
|
await deleteQuarantineFile(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
|
const key = confirmKey(row.track_id, 'delete-lidarr');
|
|
if (confirmingKey !== key) {
|
|
armConfirm(row.track_id, 'delete-lidarr');
|
|
return;
|
|
}
|
|
clearConfirm();
|
|
await runMutation(async () => {
|
|
await deleteQuarantineViaLidarr(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
|
|
// ---- Library scan ----
|
|
const scanStatusStore = $derived(createScanStatusQuery());
|
|
const scanStatusQ = $derived($scanStatusStore);
|
|
const scan = $derived(scanStatusQ.data);
|
|
const scanInFlight = $derived(scan?.in_flight === true);
|
|
|
|
// ---- Cover-art coverage gauge ----
|
|
const coverageStore = $derived(createCoverageQuery());
|
|
const coverageQ = $derived($coverageStore);
|
|
const coverage = $derived(coverageQ.data);
|
|
|
|
// ---- Fingerprint backfill gauge (#3908) ----
|
|
const fingerprintStore = $derived(createFingerprintCoverageQuery());
|
|
const fingerprintQ = $derived($fingerprintStore);
|
|
const fingerprints = $derived(fingerprintQ.data);
|
|
|
|
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() });
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
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);
|
|
|
|
// ---- Re-search missing art ----
|
|
let researchSaving = $state(false);
|
|
|
|
async function onResearchMissingArt() {
|
|
researchSaving = true;
|
|
try {
|
|
await researchMissingArt();
|
|
pushToast('All previously-failed art will be re-attempted on the next scan.');
|
|
} catch (e) {
|
|
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
|
|
} finally {
|
|
researchSaving = false;
|
|
}
|
|
}
|
|
|
|
async function onBulkRefetch() {
|
|
if (bulkBusy) return;
|
|
bulkBusy = true;
|
|
bulkResult = null;
|
|
try {
|
|
const { started } = await refetchMissingCovers();
|
|
bulkResult = started
|
|
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
|
|
: 'Could not start the cover refetch.';
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
} catch (e) {
|
|
bulkResult = `Failed: ${errCode(e)}`;
|
|
} finally {
|
|
bulkBusy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
|
|
|
<div class="space-y-8">
|
|
<header>
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2>
|
|
</header>
|
|
|
|
<!-- Top stats -->
|
|
<div class="grid gap-4 sm:grid-cols-3">
|
|
<a
|
|
href="/admin/requests"
|
|
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
|
>
|
|
<div class="text-sm text-text-secondary">Pending requests</div>
|
|
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{pendingCount}</div>
|
|
</a>
|
|
<a
|
|
href="/admin/quarantine"
|
|
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
|
>
|
|
<div class="text-sm text-text-secondary">Quarantined tracks</div>
|
|
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{quarantineCount}</div>
|
|
</a>
|
|
<a
|
|
href="/admin/integrations"
|
|
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
|
>
|
|
<div class="text-sm text-text-secondary">Lidarr</div>
|
|
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
|
|
{lidarrConnected ? 'Connected' : 'Unset'}
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Pending requests preview -->
|
|
<section class="space-y-3">
|
|
<header class="flex items-baseline justify-between">
|
|
<h3 class="font-display text-xl font-medium text-text-primary">Pending requests</h3>
|
|
{#if pendingCount > PREVIEW_LIMIT}
|
|
<a href="/admin/requests" class="text-sm text-accent hover:underline">View all {pendingCount} →</a>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if requests.isPending}
|
|
<p class="text-sm text-text-secondary">Loading…</p>
|
|
{:else if requestPreview.length === 0}
|
|
<p class="text-sm text-text-secondary">No pending requests.</p>
|
|
{:else}
|
|
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
|
|
{#each requestPreview as r (r.id)}
|
|
{@const Icon = fallbackIcon(r.kind)}
|
|
<li class="flex items-center gap-3 p-3">
|
|
<Icon size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="truncate text-sm font-medium text-text-primary">{rowTitle(r)}</div>
|
|
<div class="truncate text-xs text-text-secondary">{rowSubtitle(r)}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => onApprove(r)}
|
|
aria-label={`Approve ${rowTitle(r)}`}
|
|
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-3 text-sm text-action-fg hover:opacity-90"
|
|
>
|
|
<Check size={14} strokeWidth={1.5} /> Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => onReject(r)}
|
|
aria-label={`Reject ${rowTitle(r)}`}
|
|
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
|
|
>
|
|
<X size={14} strokeWidth={1.5} /> Reject
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/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-4">
|
|
<!-- Library stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
|
|
<StageBadge state={stageState(scan, 'library')} />
|
|
</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 if stageState(scan, 'library') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- MBID backfill stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
|
|
<StageBadge state={stageState(scan, '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>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Duplicates</dt><dd>{scan.mbid_backfill.duplicates ?? 0}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'mbid_backfill') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Cover enrichment stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
|
|
<StageBadge state={stageState(scan, 'cover_enrich')} />
|
|
</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 if stageState(scan, 'cover_enrich') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Artist art enrichment stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
|
|
<StageBadge state={stageState(scan, 'artist_art_enrich')} />
|
|
</div>
|
|
{#if scan.artist_art_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.artist_art_enrich.processed}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.artist_art_enrich.succeeded}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.artist_art_enrich.failed}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'artist_art_enrich') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</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}
|
|
|
|
<!-- Fingerprint backfill (#3908). A worker of its own rather than a scan
|
|
stage, so its progress is read live here, not from the run above. -->
|
|
{#if fingerprints && fingerprints.total > 0}
|
|
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
|
|
<span class="text-xs font-medium uppercase tracking-wide text-text-muted">Fingerprints</span>
|
|
<span>{fingerprints.fingerprinted.toLocaleString()} of {fingerprints.total.toLocaleString()} tracks</span>
|
|
{#if fingerprints.pending > 0}
|
|
<span class="text-text-muted">·</span>
|
|
<span>{fingerprints.pending.toLocaleString()} pending</span>
|
|
{/if}
|
|
{#if fingerprints.rejected > 0}
|
|
<span class="text-text-muted">·</span>
|
|
<span
|
|
class="cursor-help"
|
|
title="The fingerprint tools could not read these files. Each is tried again when its file changes."
|
|
>
|
|
{fingerprints.rejected.toLocaleString()} unreadable
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/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>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Refetch cover art for albums that are missing one or where the previous attempt failed.
|
|
</p>
|
|
<div class="mt-3 flex flex-wrap items-center gap-4">
|
|
<button
|
|
type="button"
|
|
onclick={onBulkRefetch}
|
|
disabled={bulkBusy}
|
|
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{bulkBusy ? 'Queueing…' : 'Refetch missing covers'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={onResearchMissingArt}
|
|
disabled={researchSaving}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
|
>
|
|
{researchSaving ? 'Bumping…' : 'Re-search missing art'}
|
|
</button>
|
|
|
|
{#if coverage}
|
|
{#if coverage.total === 0}
|
|
<span class="text-sm text-text-muted">Library is empty.</span>
|
|
{:else}
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<span>{coverage.with_art.toLocaleString()} with art</span>
|
|
<span class="text-text-muted">·</span>
|
|
<span
|
|
class={coverage.pending_no_mbid > 0 ? 'cursor-help' : ''}
|
|
title={coverage.pending_no_mbid > 0
|
|
? `${coverage.pending_no_mbid.toLocaleString()} of these have no MBID and won't be helped by another scan — fix tags or merge duplicate albums.`
|
|
: undefined}
|
|
>
|
|
{coverage.pending.toLocaleString()} pending
|
|
</span>
|
|
<span class="text-text-muted">·</span>
|
|
<span>{coverage.settled.toLocaleString()} settled</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{#if bulkResult}
|
|
<p class="mt-2 text-sm">{bulkResult}</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Quarantine preview -->
|
|
<section class="space-y-3">
|
|
<header class="flex items-baseline justify-between">
|
|
<h3 class="font-display text-xl font-medium text-text-primary">Quarantine</h3>
|
|
{#if quarantineCount > PREVIEW_LIMIT}
|
|
<a href="/admin/quarantine" class="text-sm text-accent hover:underline">View all {quarantineCount} →</a>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if quarantine.isPending}
|
|
<p class="text-sm text-text-secondary">Loading…</p>
|
|
{:else if quarantinePreview.length === 0}
|
|
<p class="text-sm text-text-secondary">No quarantined tracks.</p>
|
|
{:else}
|
|
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
|
|
{#each quarantinePreview as row (row.track_id)}
|
|
{@const fileKey = confirmKey(row.track_id, 'delete-file')}
|
|
{@const lidarrKey = confirmKey(row.track_id, 'delete-lidarr')}
|
|
{@const fileArmed = confirmingKey === fileKey}
|
|
{@const lidarrArmed = confirmingKey === lidarrKey}
|
|
<li class="flex items-center gap-3 p-3">
|
|
<Music2 size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="truncate text-sm font-medium text-text-primary">{row.track_title}</div>
|
|
<div class="truncate text-xs text-text-secondary">
|
|
{row.artist_name} · {row.album_title}
|
|
· {row.report_count} {row.report_count === 1 ? 'report' : 'reports'}
|
|
{#if row.reports[0]}
|
|
· {REASON_LABELS[row.reports[0].reason] ?? row.reports[0].reason}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => onResolve(row)}
|
|
aria-label={`Resolve ${row.track_title}`}
|
|
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
|
|
>
|
|
<RotateCcw size={14} strokeWidth={1.5} /> Resolve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => onDeleteFile(row)}
|
|
aria-label={fileArmed ? `Confirm delete file ${row.track_title}` : `Delete file ${row.track_title}`}
|
|
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {fileArmed
|
|
? 'bg-action-secondary text-action-fg'
|
|
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
|
>
|
|
<Trash2 size={14} strokeWidth={1.5} /> {fileArmed ? 'Confirm?' : 'Delete file'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => onDeleteLidarr(row)}
|
|
aria-label={lidarrArmed ? `Confirm delete via Lidarr ${row.track_title}` : `Delete via Lidarr ${row.track_title}`}
|
|
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {lidarrArmed
|
|
? 'bg-action-destructive text-action-fg'
|
|
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
|
>
|
|
<Cloud size={14} strokeWidth={1.5} /> {lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
|
|
</button>
|
|
<a
|
|
href="/admin/quarantine"
|
|
aria-label="Open in quarantine queue"
|
|
class="text-text-secondary hover:text-text-primary"
|
|
>
|
|
<ChevronRight size={16} strokeWidth={1.5} />
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
|