add scanning

This commit is contained in:
Garret Patti
2026-04-05 18:55:53 -04:00
parent c87a9b33bb
commit 8829188c58
11 changed files with 872 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import cron from 'node-cron'
import { requireAdmin } from '@/lib/auth'
import { getScanConfig, updateScanConfig } from '@/lib/app-settings'
import { restartScheduler } from '@/lib/scheduler'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { schedule, enabled } = getScanConfig()
return NextResponse.json({ schedule, enabled })
}
export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { schedule?: string; enabled?: boolean }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { schedule, enabled } = body
if (typeof schedule !== 'string' || !schedule.trim()) {
return NextResponse.json({ error: 'schedule is required' }, { status: 400 })
}
if (typeof enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
}
if (!cron.validate(schedule)) {
return NextResponse.json({ error: 'Invalid cron expression' }, { status: 400 })
}
updateScanConfig(schedule, enabled)
restartScheduler()
return NextResponse.json({ schedule, enabled })
}

32
src/app/api/scan/route.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { isScanRunning, runFullScan } from '@/lib/scanner'
import { getScanConfig } from '@/lib/app-settings'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const config = getScanConfig()
return NextResponse.json({
isRunning: isScanRunning(),
lastScanAt: config.lastScanAt,
schedule: config.schedule,
enabled: config.enabled,
})
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
if (isScanRunning()) {
return NextResponse.json({ started: false, reason: 'already running' }, { status: 409 })
}
// Fire-and-forget — do not await
runFullScan().catch((err) => console.error('[api/scan] Scan error:', err))
return NextResponse.json({ started: true }, { status: 202 })
}

View File

@@ -0,0 +1,287 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
interface ScanStatus {
isRunning: boolean
lastScanAt: number | null
schedule: string
enabled: boolean
}
interface ScanSettings {
schedule: string
enabled: boolean
}
function formatDate(ts: number | null): string {
if (!ts) return 'Never'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(ts))
}
export default function ScanningPage() {
const [status, setStatus] = useState<ScanStatus | null>(null)
const [settings, setSettings] = useState<ScanSettings>({ schedule: '0 * * * *', enabled: true })
const [loadingStatus, setLoadingStatus] = useState(true)
const [scanning, setScanning] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveSuccess, setSaveSuccess] = useState(false)
const [savingSettings, setSavingSettings] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/scan')
if (!res.ok) return
const data: ScanStatus = await res.json()
setStatus(data)
setScanning(data.isRunning)
setSettings({ schedule: data.schedule, enabled: data.enabled })
} catch {
// ignore
} finally {
setLoadingStatus(false)
}
}, [])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// Poll every 2s while a scan is in progress
useEffect(() => {
if (scanning) {
pollRef.current = setInterval(fetchStatus, 2000)
} else {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [scanning, fetchStatus])
const handleScanNow = async () => {
if (scanning) return
try {
const res = await fetch('/api/scan', { method: 'POST' })
if (res.status === 202) {
setScanning(true)
fetchStatus()
} else if (res.status === 409) {
setScanning(true)
}
} catch {
// ignore
}
}
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault()
setSaveError(null)
setSaveSuccess(false)
setSavingSettings(true)
try {
const res = await fetch('/api/scan-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
const data = await res.json()
if (!res.ok) {
setSaveError(data.error ?? 'Failed to save settings')
} else {
setSettings(data)
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000)
}
} catch {
setSaveError('Network error. Please try again.')
} finally {
setSavingSettings(false)
}
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Library Scanning
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Scan libraries to index metadata and pre-generate thumbnails.
</p>
<Section title="Status">
{loadingStatus ? (
<LoadingRows />
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{scanning ? 'Scanning…' : 'Idle'}
</span>
{scanning && (
<span
className="text-xs px-2 py-0.5 rounded-full animate-pulse"
style={{ backgroundColor: '#16a34a33', color: '#4ade80' }}
>
Running
</span>
)}
</div>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Last scan: {formatDate(status?.lastScanAt ?? null)}
</span>
</div>
<button
onClick={handleScanNow}
disabled={scanning}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!scanning) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{scanning ? 'Scanning…' : 'Scan Now'}
</button>
</div>
)}
</Section>
<Section title="Schedule">
<form onSubmit={handleSaveSettings} className="flex flex-col gap-5">
<Field label="Cron Expression">
<input
type="text"
value={settings.schedule}
onChange={(e) => setSettings((s) => ({ ...s, schedule: e.target.value }))}
placeholder="0 * * * *"
required
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
Standard 5-field cron (minute hour day month weekday). Default: <code className="font-mono">0 * * * *</code> (hourly).
</p>
</Field>
<Field label="Automatic Scanning">
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
role="switch"
aria-checked={settings.enabled}
onClick={() => setSettings((s) => ({ ...s, enabled: !s.enabled }))}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer"
style={{
backgroundColor: settings.enabled ? 'var(--accent)' : 'var(--border)',
}}
>
<span
className="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform"
style={{ transform: settings.enabled ? 'translateX(18px)' : 'translateX(3px)' }}
/>
</div>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{settings.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</Field>
{saveError && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{saveError}
</p>
)}
{saveSuccess && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
>
Settings saved.
</p>
)}
<div>
<button
type="submit"
disabled={savingSettings}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!savingSettings) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{savingSettings ? 'Saving…' : 'Save Settings'}
</button>
</div>
</form>
</Section>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{children}
</div>
)
}
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}