diff --git a/web/src/lib/api/admin.scan.test.ts b/web/src/lib/api/admin.scan.test.ts new file mode 100644 index 00000000..9c9eb28f --- /dev/null +++ b/web/src/lib/api/admin.scan.test.ts @@ -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).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).mockResolvedValueOnce({ id: 's2' }); + const got = await triggerScan(); + expect(api.post).toHaveBeenCalledWith('/api/admin/scan/run', {}); + expect(got.id).toBe('s2'); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index f63dc685..29d0b111 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -181,3 +181,57 @@ export type RefetchMissingResponse = { export async function refetchMissingCovers(): Promise { return api.post('/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 { + return api.get('/api/admin/scan/status'); +} + +export type TriggerScanResp = { id?: string }; + +export async function triggerScan(): Promise { + return api.post('/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 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 7cc90b95..dd0f6f4d 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -34,6 +34,7 @@ export const qk = { adminQuarantine: () => ['adminQuarantine'] as const, adminQuarantineActions: (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const, + scanStatus: () => ['scanStatus'] as const, suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, home: () => ['home'] as const, diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index f715a13f..aed8e7d5 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -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(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(null); @@ -276,6 +313,86 @@ {/if} + +
+
+
+

Library scan

+

+ Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers. +

+
+ +
+ + {#if !scan || !scan.id} +

No scan has run yet.

+ {:else} +
+ +
+
Library walk
+ {#if scan.library} +
+
Scanned
{scan.library.scanned}
+
Added
{scan.library.added}
+
Updated
{scan.library.updated}
+
Skipped
{scan.library.skipped}
+
Errored
{scan.library.errored}
+
+ {:else} +

{scanInFlight ? 'Pending…' : 'Skipped.'}

+ {/if} +
+ + +
+
MBID backfill
+ {#if scan.mbid_backfill} +
+
Processed
{scan.mbid_backfill.processed}
+
Healed
{scan.mbid_backfill.healed}
+
Skipped
{scan.mbid_backfill.skipped}
+
+ {:else} +

{scanInFlight ? 'Pending…' : 'Skipped.'}

+ {/if} +
+ + +
+
Cover enrichment
+ {#if scan.cover_enrich} +
+
Processed
{scan.cover_enrich.processed}
+
Succeeded
{scan.cover_enrich.succeeded}
+
Failed
{scan.cover_enrich.failed}
+
+ {:else} +

{scanInFlight ? 'Pending…' : 'Skipped.'}

+ {/if} +
+
+ +
+ Started {formatTime(scan.started_at)} + {#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if} + {#if scan.error_message}· Error: {scan.error_message}{/if} +
+ {/if} + + {#if triggerResult} +

{triggerResult}

+ {/if} +
+

Cover art