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:
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { getScanStatus, triggerScan } from './admin';
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
describe('admin scan API', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('getScanStatus GETs the correct path', async () => {
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
id: 's1', started_at: '2026-05-04T00:00:00Z', finished_at: null, in_flight: true
|
||||||
|
});
|
||||||
|
const got = await getScanStatus();
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/status');
|
||||||
|
expect(got.in_flight).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggerScan POSTs an empty body to /run', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: 's2' });
|
||||||
|
const got = await triggerScan();
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/scan/run', {});
|
||||||
|
expect(got.id).toBe('s2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -181,3 +181,57 @@ export type RefetchMissingResponse = {
|
|||||||
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
||||||
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Library scan -------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ScanStageLibrary = {
|
||||||
|
scanned: number;
|
||||||
|
added: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
errored: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScanStageMbidBackfill = {
|
||||||
|
processed: number;
|
||||||
|
healed: number;
|
||||||
|
skipped: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScanStageCoverEnrich = {
|
||||||
|
processed: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScanStatus = {
|
||||||
|
id: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
library?: ScanStageLibrary;
|
||||||
|
mbid_backfill?: ScanStageMbidBackfill;
|
||||||
|
cover_enrich?: ScanStageCoverEnrich;
|
||||||
|
error_message?: string;
|
||||||
|
in_flight: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getScanStatus(): Promise<ScanStatus> {
|
||||||
|
return api.get<ScanStatus>('/api/admin/scan/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerScanResp = { id?: string };
|
||||||
|
|
||||||
|
export async function triggerScan(): Promise<TriggerScanResp> {
|
||||||
|
return api.post<TriggerScanResp>('/api/admin/scan/run', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polls every 3s while a scan is in flight; falls back to a 30s stale-time
|
||||||
|
// when idle so the section auto-updates as workers complete each stage.
|
||||||
|
export function createScanStatusQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.scanStatus(),
|
||||||
|
queryFn: getScanStatus,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 3_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const qk = {
|
|||||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||||
adminQuarantineActions: (limit?: number) =>
|
adminQuarantineActions: (limit?: number) =>
|
||||||
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
||||||
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
suggestions: (limit?: number) =>
|
suggestions: (limit?: number) =>
|
||||||
['suggestions', { limit: limit ?? 12 }] as const,
|
['suggestions', { limit: limit ?? 12 }] as const,
|
||||||
home: () => ['home'] as const,
|
home: () => ['home'] as const,
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
createAdminRequestsQuery,
|
createAdminRequestsQuery,
|
||||||
createLidarrConfigQuery,
|
createLidarrConfigQuery,
|
||||||
createAdminQuarantineQuery,
|
createAdminQuarantineQuery,
|
||||||
|
createScanStatusQuery,
|
||||||
approveRequest,
|
approveRequest,
|
||||||
rejectRequest,
|
rejectRequest,
|
||||||
resolveQuarantine,
|
resolveQuarantine,
|
||||||
deleteQuarantineFile,
|
deleteQuarantineFile,
|
||||||
deleteQuarantineViaLidarr,
|
deleteQuarantineViaLidarr,
|
||||||
refetchMissingCovers
|
refetchMissingCovers,
|
||||||
|
triggerScan
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { copyForCode } from '$lib/api/error-copy';
|
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 ----
|
// ---- Cover art bulk refetch ----
|
||||||
let bulkBusy = $state(false);
|
let bulkBusy = $state(false);
|
||||||
let bulkResult = $state<string | null>(null);
|
let bulkResult = $state<string | null>(null);
|
||||||
@@ -276,6 +313,86 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Cover art bulk refetch -->
|
||||||
<section class="rounded border border-border bg-surface p-4">
|
<section class="rounded border border-border bg-surface p-4">
|
||||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user