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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const TABS = [
|
||||
{ href: '/manage', label: 'Libraries' },
|
||||
{ href: '/manage/tags', label: 'Tags' },
|
||||
{ href: '/manage/users', label: 'Users' },
|
||||
{ href: '/manage/scanning', label: 'Scanning' },
|
||||
]
|
||||
|
||||
export default function ManageSubNav() {
|
||||
|
||||
@@ -2,5 +2,8 @@ export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initializeSecret } = await import('./lib/secret')
|
||||
initializeSecret()
|
||||
|
||||
const { startScheduler } = await import('./lib/scheduler')
|
||||
startScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
38
src/lib/app-settings.ts
Normal file
38
src/lib/app-settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
interface ScanConfig {
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
lastScanAt: number | null
|
||||
}
|
||||
|
||||
function getSetting(key: string): string | null {
|
||||
const db = getDb()
|
||||
const row = db
|
||||
.prepare('SELECT value FROM app_settings WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined
|
||||
return row?.value ?? null
|
||||
}
|
||||
|
||||
function setSetting(key: string, value: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value)
|
||||
}
|
||||
|
||||
export function getScanConfig(): ScanConfig {
|
||||
const schedule = getSetting('scan_schedule') ?? '0 * * * *'
|
||||
const enabled = getSetting('scan_enabled') !== 'false'
|
||||
const lastRanRaw = getSetting('scan_last_ran')
|
||||
const lastScanAt =
|
||||
lastRanRaw && lastRanRaw.length > 0 ? parseInt(lastRanRaw, 10) : null
|
||||
return { schedule, enabled, lastScanAt }
|
||||
}
|
||||
|
||||
export function updateScanConfig(schedule: string, enabled: boolean): void {
|
||||
setSetting('scan_schedule', schedule)
|
||||
setSetting('scan_enabled', enabled ? 'true' : 'false')
|
||||
}
|
||||
|
||||
export function setScanLastRan(ts: number): void {
|
||||
setSetting('scan_last_ran', String(ts))
|
||||
}
|
||||
@@ -71,9 +71,46 @@ function initDb(db: Database.Database): void {
|
||||
tv_loop INTEGER NOT NULL DEFAULT 0,
|
||||
tv_muted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
item_key TEXT NOT NULL UNIQUE,
|
||||
item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series')),
|
||||
parent_key TEXT,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
plot TEXT,
|
||||
genres TEXT,
|
||||
metadata TEXT,
|
||||
scanned_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key);
|
||||
`)
|
||||
|
||||
migrateLibrariesType(db)
|
||||
seedAppSettings(db)
|
||||
}
|
||||
|
||||
function seedAppSettings(db: Database.Database): void {
|
||||
const defaults: Record<string, string> = {
|
||||
scan_schedule: '0 * * * *',
|
||||
scan_enabled: 'true',
|
||||
scan_last_ran: '',
|
||||
}
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
||||
)
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
insert.run(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLibrariesType(db: Database.Database): void {
|
||||
|
||||
373
src/lib/scanner.ts
Normal file
373
src/lib/scanner.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import path from 'path'
|
||||
import type Database from 'better-sqlite3'
|
||||
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
|
||||
import { getDb } from './db'
|
||||
import { getLibraries, resolveLibraryRoot } from './libraries'
|
||||
import { setScanLastRan } from './app-settings'
|
||||
import { scanMoviesLibrary } from './movies'
|
||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||
import { scanGamesLibrary } from './games'
|
||||
import { getThumbnailPath } from './thumbnails'
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
|
||||
|
||||
let scanRunning = false
|
||||
|
||||
export function isScanRunning(): boolean {
|
||||
return scanRunning
|
||||
}
|
||||
|
||||
export async function runFullScan(): Promise<void> {
|
||||
if (scanRunning) return
|
||||
scanRunning = true
|
||||
console.log('[scanner] Starting full library scan')
|
||||
try {
|
||||
const libraries = getLibraries()
|
||||
for (const library of libraries) {
|
||||
try {
|
||||
await runLibraryScan(library)
|
||||
} catch (err) {
|
||||
console.error(`[scanner] Error scanning library "${library.name}":`, err)
|
||||
}
|
||||
}
|
||||
const now = Date.now()
|
||||
setScanLastRan(now)
|
||||
console.log('[scanner] Full scan complete')
|
||||
} finally {
|
||||
scanRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLibraryScan(library: Library): Promise<void> {
|
||||
const libraryRoot = resolveLibraryRoot(library)
|
||||
console.log(`[scanner] Scanning library "${library.name}" (${library.type}) at ${libraryRoot}`)
|
||||
|
||||
switch (library.type) {
|
||||
case 'movies':
|
||||
await scanMovies(library, libraryRoot)
|
||||
break
|
||||
case 'tv':
|
||||
await scanTv(library, libraryRoot)
|
||||
break
|
||||
case 'games':
|
||||
await scanGames(library, libraryRoot)
|
||||
break
|
||||
case 'mixed':
|
||||
await scanMixed(library, libraryRoot)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Movies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scanMovies(library: Library, libraryRoot: string): Promise<void> {
|
||||
const movies = scanMoviesLibrary(libraryRoot, library.id)
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
year = excluded.year,
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
for (const movie of movies) {
|
||||
const itemKey = `${library.id}:movie:${movie.id}`
|
||||
upsert.run({
|
||||
library_id: library.id,
|
||||
item_key: itemKey,
|
||||
item_type: 'movie',
|
||||
title: movie.title,
|
||||
year: movie.year ?? null,
|
||||
plot: movie.plot ?? null,
|
||||
genres: JSON.stringify(movie.genres),
|
||||
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
// Pre-generate poster thumbnail
|
||||
if (movie.posterUrl) {
|
||||
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scanner] movies: indexed ${movies.length} items`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TV
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
const series = scanTvLibrary(libraryRoot, library.id)
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertSeries = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
year = excluded.year,
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
const upsertChild = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
parent_key = excluded.parent_key,
|
||||
title = excluded.title,
|
||||
year = excluded.year,
|
||||
plot = excluded.plot,
|
||||
genres = excluded.genres,
|
||||
metadata = excluded.metadata,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
let episodeCount = 0
|
||||
|
||||
for (const show of series) {
|
||||
const seriesKey = `${library.id}:tv_series:${show.id}`
|
||||
upsertSeries.run({
|
||||
library_id: library.id,
|
||||
item_key: seriesKey,
|
||||
item_type: 'tv_series',
|
||||
title: show.title,
|
||||
year: show.year ?? null,
|
||||
plot: show.plot ?? null,
|
||||
genres: JSON.stringify(show.genres),
|
||||
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (show.posterUrl) {
|
||||
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
|
||||
const seasons = scanTvSeasons(libraryRoot, library.id, show.id)
|
||||
for (const season of seasons) {
|
||||
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
|
||||
upsertChild.run({
|
||||
library_id: library.id,
|
||||
item_key: seasonKey,
|
||||
item_type: 'tv_season',
|
||||
parent_key: seriesKey,
|
||||
title: season.title,
|
||||
year: null,
|
||||
plot: null,
|
||||
genres: JSON.stringify([]),
|
||||
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (season.posterUrl) {
|
||||
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
|
||||
const episodes = scanTvEpisodes(libraryRoot, library.id, show.id, season.id)
|
||||
for (const episode of episodes) {
|
||||
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
|
||||
upsertChild.run({
|
||||
library_id: library.id,
|
||||
item_key: episodeKey,
|
||||
item_type: 'tv_episode',
|
||||
parent_key: seasonKey,
|
||||
title: episode.title,
|
||||
year: null,
|
||||
plot: episode.plot ?? null,
|
||||
genres: JSON.stringify([]),
|
||||
metadata: JSON.stringify({
|
||||
episodeNumber: episode.episodeNumber,
|
||||
seasonNumber: episode.seasonNumber,
|
||||
aired: episode.aired,
|
||||
rating: episode.rating,
|
||||
}),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
// Pre-generate video thumbnail (seek-based frame extraction)
|
||||
const videoAbsPath = path.join(libraryRoot, episode.videoPath)
|
||||
try {
|
||||
await getThumbnailPath(videoAbsPath, library.id, 'video')
|
||||
} catch (err) {
|
||||
console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
episodeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scanner] tv: indexed ${series.length} series, ${episodeCount} episodes`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Games
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
const items = scanGamesLibrary(libraryRoot, library.id)
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertGame = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
const upsertChildGame = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
parent_key = excluded.parent_key,
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
let gameCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
if ('games' in item) {
|
||||
// GameSeries
|
||||
const series = item as GameSeries
|
||||
const seriesKey = `${library.id}:game_series:${series.id}`
|
||||
upsertGame.run({
|
||||
library_id: library.id,
|
||||
item_key: seriesKey,
|
||||
item_type: 'game_series',
|
||||
title: series.title,
|
||||
metadata: JSON.stringify({ gameCount: series.games.length }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (series.coverUrl) {
|
||||
await prewarmThumbnailFromUrl(series.coverUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
|
||||
for (const game of series.games) {
|
||||
const gameKey = `${library.id}:game:${game.id}`
|
||||
upsertChildGame.run({
|
||||
library_id: library.id,
|
||||
item_key: gameKey,
|
||||
item_type: 'game',
|
||||
parent_key: seriesKey,
|
||||
title: game.title,
|
||||
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (game.coverUrl) {
|
||||
await prewarmThumbnailFromUrl(game.coverUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
gameCount++
|
||||
}
|
||||
} else {
|
||||
// Standalone Game
|
||||
const game = item as Game
|
||||
const gameKey = `${library.id}:game:${game.id}`
|
||||
upsertGame.run({
|
||||
library_id: library.id,
|
||||
item_key: gameKey,
|
||||
item_type: 'game',
|
||||
title: game.title,
|
||||
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (game.coverUrl) {
|
||||
await prewarmThumbnailFromUrl(game.coverUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
gameCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scanner] games: indexed ${gameCount} games`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed (thumbnail pre-generation only — no DB indexing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
||||
const fs = await import('fs')
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isFile() && !d.name.startsWith('.'))
|
||||
.map((d) => d.name) as unknown as string[]
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const filename of entries) {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
let mediaType: 'image' | 'video' | null = null
|
||||
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
|
||||
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
|
||||
if (!mediaType) continue
|
||||
|
||||
const absPath = path.join(libraryRoot, filename)
|
||||
try {
|
||||
await getThumbnailPath(absPath, library.id, mediaType)
|
||||
count++
|
||||
} catch (err) {
|
||||
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function clearLibraryItems(db: Database.Database, libraryId: string): void {
|
||||
db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `path` query param from an /api/thumbnail URL and pre-warm
|
||||
* the thumbnail cache for that file.
|
||||
*/
|
||||
async function prewarmThumbnailFromUrl(
|
||||
apiUrl: string,
|
||||
libraryId: string,
|
||||
libraryRoot: string,
|
||||
mediaType: 'image' | 'video'
|
||||
): Promise<void> {
|
||||
try {
|
||||
const relPath = decodeURIComponent(
|
||||
new URL(apiUrl, 'http://localhost').searchParams.get('path') ?? ''
|
||||
)
|
||||
if (!relPath) return
|
||||
const absPath = path.join(libraryRoot, relPath)
|
||||
await getThumbnailPath(absPath, libraryId, mediaType)
|
||||
} catch (err) {
|
||||
console.warn(`[scanner] Could not prewarm thumbnail for ${apiUrl}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
39
src/lib/scheduler.ts
Normal file
39
src/lib/scheduler.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import cron, { type ScheduledTask } from 'node-cron'
|
||||
import { getScanConfig } from './app-settings'
|
||||
import { runFullScan } from './scanner'
|
||||
|
||||
let scheduledTask: ScheduledTask | null = null
|
||||
|
||||
export function startScheduler(): void {
|
||||
const { schedule, enabled } = getScanConfig()
|
||||
|
||||
if (!enabled) {
|
||||
console.log('[scheduler] Scanning is disabled — scheduler not started')
|
||||
return
|
||||
}
|
||||
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error(`[scheduler] Invalid cron expression "${schedule}" — scheduler not started`)
|
||||
return
|
||||
}
|
||||
|
||||
scheduledTask = cron.schedule(schedule, () => {
|
||||
console.log('[scheduler] Cron triggered — running full scan')
|
||||
runFullScan().catch((err) => console.error('[scheduler] Scan error:', err))
|
||||
})
|
||||
|
||||
console.log(`[scheduler] Started with schedule: ${schedule}`)
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (scheduledTask) {
|
||||
scheduledTask.stop()
|
||||
scheduledTask = null
|
||||
console.log('[scheduler] Stopped')
|
||||
}
|
||||
}
|
||||
|
||||
export function restartScheduler(): void {
|
||||
stopScheduler()
|
||||
startScheduler()
|
||||
}
|
||||
Reference in New Issue
Block a user