diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 56f84b66..7bf0f223 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -10,13 +10,17 @@ createAdminQuarantineQuery, createScanStatusQuery, createCoverageQuery, + createScanScheduleQuery, + updateScanSchedule, approveRequest, rejectRequest, resolveQuarantine, deleteQuarantineFile, deleteQuarantineViaLidarr, refetchMissingCovers, - triggerScan + triggerScan, + type ScanSchedule, + type ScanScheduleMode } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import { copyForCode } from '$lib/api/error-copy'; @@ -224,6 +228,89 @@ return new Date(iso).toLocaleString(); } + // ---- Scan schedule ---- + const scheduleStore = $derived(createScanScheduleQuery()); + const scheduleQ = $derived($scheduleStore); + const schedule = $derived(scheduleQ.data); + + let scheduleLocal = $state<{ + mode: ScanScheduleMode; + interval_hours: number; + time_of_day: string; + weekly_day: number; + }>({ + mode: 'off', + interval_hours: 6, + time_of_day: '03:00', + weekly_day: 7, + }); + let scheduleSaving = $state(false); + + $effect(() => { + if (schedule) { + scheduleLocal = { + mode: schedule.mode, + interval_hours: schedule.interval_hours ?? 6, + time_of_day: schedule.time_of_day ?? '03:00', + weekly_day: schedule.weekly_day ?? 7, + }; + } + }); + + function scheduleHasChanges(): boolean { + if (!schedule) return false; + if (scheduleLocal.mode !== schedule.mode) return true; + switch (scheduleLocal.mode) { + case 'interval': + return scheduleLocal.interval_hours !== (schedule.interval_hours ?? -1); + case 'daily': + return scheduleLocal.time_of_day !== (schedule.time_of_day ?? ''); + case 'weekly': + return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '') + || scheduleLocal.weekly_day !== (schedule.weekly_day ?? -1); + default: + return false; + } + } + + async function onSaveSchedule() { + scheduleSaving = true; + try { + const patch: { mode: ScanScheduleMode; interval_hours?: number; time_of_day?: string; weekly_day?: number } = { + mode: scheduleLocal.mode, + }; + if (scheduleLocal.mode === 'interval') patch.interval_hours = scheduleLocal.interval_hours; + if (scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly') patch.time_of_day = scheduleLocal.time_of_day; + if (scheduleLocal.mode === 'weekly') patch.weekly_day = scheduleLocal.weekly_day; + await updateScanSchedule(patch); + await client.invalidateQueries({ queryKey: qk.scanSchedule() }); + } catch (e) { + showToast(`Schedule save failed: ${(e as { code?: string })?.code ?? 'unknown'}`); + } finally { + scheduleSaving = false; + } + } + + function formatRelative(iso: string): string { + const target = new Date(iso); + const now = new Date(); + const diffMs = target.getTime() - now.getTime(); + const absHours = Math.abs(diffMs) / 3_600_000; + let rel: string; + if (absHours < 1) { + const mins = Math.round(Math.abs(diffMs) / 60_000); + rel = diffMs >= 0 ? `in ${mins} min` : `${mins} min ago`; + } else if (absHours < 48) { + const h = Math.round(absHours); + rel = diffMs >= 0 ? `in ${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ago`; + } else { + const days = Math.round(absHours / 24); + rel = diffMs >= 0 ? `in ${days} days` : `${days} days ago`; + } + const local = target.toLocaleString(); + return `${rel} (${local})`; + } + // ---- Cover art bulk refetch ---- let bulkBusy = $state(false); let bulkResult = $state(null); @@ -430,6 +517,77 @@ {/if} + +
+
+

Scan schedule

+

+ Run a library scan automatically on a recurring cadence. Manual triggers + and boot scans are unaffected. +

+
+ + {#if schedule} +
+ + + + {#if scheduleLocal.mode === 'interval'} + +
+ + hours +
+ {/if} + + {#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'} + + + {/if} + + {#if scheduleLocal.mode === 'weekly'} + + + {/if} +
+ +
+ + {#if schedule.next_scheduled_at} + + Next scan: {formatRelative(schedule.next_scheduled_at)} + + {:else if scheduleLocal.mode === 'off'} + Schedule is off. + {/if} +
+ {:else} +

Loading…

+ {/if} +
+

Cover art

diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index 153ccc72..c76c2576 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -41,7 +41,21 @@ vi.mock('$lib/api/admin', async () => { deleteQuarantineFile: vi.fn().mockResolvedValue({}), deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}), triggerScan: vi.fn().mockResolvedValue({}), - refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 }) + refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 }), + createScanScheduleQuery: vi.fn(() => + readable({ + data: { + mode: 'off', + interval_hours: null, + time_of_day: null, + weekly_day: null, + next_scheduled_at: null, + }, + isPending: false, + isError: false + }) + ), + updateScanSchedule: vi.fn().mockResolvedValue({}) }; }); @@ -242,6 +256,14 @@ describe('admin Overview page', () => { ); }); + test('renders the scan-schedule section with mode=off default', () => { + setupOverview(); + render(OverviewPage); + expect(screen.getByText('Scan schedule')).toBeInTheDocument(); + const select = screen.getByLabelText('Mode') as HTMLSelectElement; + expect(select.value).toBe('off'); + }); + test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => { const { readable } = await import('svelte/store'); (createScanStatusQuery as ReturnType).mockReturnValueOnce(