add scanning
This commit is contained in:
42
src/app/api/scan-settings/route.ts
Normal file
42
src/app/api/scan-settings/route.ts
Normal 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
32
src/app/api/scan/route.ts
Normal 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 })
|
||||
}
|
||||
287
src/app/manage/scanning/page.tsx
Normal file
287
src/app/manage/scanning/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user