diff --git a/server/api/scan.ts b/server/api/scan.ts index 7a5775c..de1f35b 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -1,10 +1,12 @@ import { Hono } from "hono"; import { stream } from "hono/streaming"; import { getAllConfig, getConfig, getDb, setConfig } from "../db/index"; -import { log, error as logError, warn } from "../lib/log"; -import { getAllItems, getDevItems } from "../services/jellyfin"; +import { log, error as logError } from "../lib/log"; +import { discoverVideoFiles } from "../services/discover"; +import { parsePath } from "../services/path-parser"; +import { probeFile } from "../services/probe"; +import { upsertScannedItem } from "../services/rescan"; import { emitPipelineChanged } from "./execute"; -import { upsertJellyfinItem } from "../services/rescan"; import { isInScanWindow, msUntilScanWindow, nextScanWindowTime, waitForScanWindow } from "../services/scheduler"; const app = new Hono(); @@ -37,7 +39,6 @@ function currentScanLimit(): number | null { return v ? Number(v) : null; } - // ─── Status ─────────────────────────────────────────────────────────────────── app.get("/", (c) => { @@ -51,7 +52,7 @@ app.get("/", (c) => { .n; const recentItems = db .prepare( - "SELECT name, type, scan_status, file_path, last_scanned_at, ingest_source FROM media_items ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC LIMIT 5", + "SELECT name, type, scan_status, file_path, last_scanned_at FROM media_items ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC LIMIT 5", ) .all() as { name: string; @@ -59,7 +60,6 @@ app.get("/", (c) => { scan_status: string; file_path: string; last_scanned_at: string | null; - ingest_source: string | null; }[]; return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() }); @@ -148,45 +148,28 @@ async function runScan(limit: number | null = null): Promise { log(`Scan started${limit ? ` (limit: ${limit})` : ""}`); scanAbort = new AbortController(); const { signal } = scanAbort; - const isDev = process.env.NODE_ENV === "development"; const db = getDb(); - if (isDev) { - // Order matters only if foreign keys are enforced without CASCADE; we - // have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/ - // jobs, so deleting media_items would be enough. List them explicitly - // for clarity and to survive future schema drift. - db.prepare("DELETE FROM jobs").run(); - db.prepare("DELETE FROM stream_decisions").run(); - db.prepare("DELETE FROM review_plans").run(); - db.prepare("DELETE FROM media_streams").run(); - db.prepare("DELETE FROM media_items").run(); - } - const cfg = getAllConfig(); - const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id }; - const rescanCfg = {}; + const moviesRoot = cfg.movies_root || "/movies"; + const tvRoot = cfg.tv_root || "/tv"; let processed = 0; let errors = 0; - let total = 0; - const itemSource = isDev - ? getDevItems(jellyfinCfg) - : getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => { - total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal; - }); - for await (const jellyfinItem of itemSource) { + emitSse("log", { message: "Discovering files..." }); + const allFiles = await discoverVideoFiles([moviesRoot, tvRoot]); + const total = limit != null ? Math.min(limit, allFiles.length) : allFiles.length; + + emitSse("progress", { scanned: 0, total, current_item: null, errors, running: true }); + + for (const filePath of allFiles) { if (signal.aborted) break; - if (!isDev && limit != null && processed >= limit) break; - if (!jellyfinItem.Name || !jellyfinItem.Path) { - warn(`Skipping item without name/path: id=${jellyfinItem.Id}`); - continue; - } + if (limit != null && processed >= limit) break; - // Honour the scan window between items so overnight-only setups don't hog - // Jellyfin during the day. Checked between items rather than mid-item so - // we don't leave a partial upsert sitting in flight. + // Honour the scan window between items so overnight-only setups don't + // hog the filesystem during the day. Checked between items rather than + // mid-item so we don't leave a partial upsert in flight. if (!isInScanWindow()) { emitSse("paused", { until: nextScanWindowTime(), @@ -197,27 +180,32 @@ async function runScan(limit: number | null = null): Promise { emitSse("resumed", {}); } + const parsed = parsePath(filePath, moviesRoot, tvRoot); + if (!parsed) continue; + processed++; - emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true }); + emitSse("progress", { scanned: processed, total, current_item: parsed.name, errors, running: true }); try { - await upsertJellyfinItem(db, jellyfinItem, rescanCfg); + const probe = await probeFile(filePath); + upsertScannedItem(db, filePath, parsed, probe); if (processed % 25 === 0) emitPipelineChanged(); - emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "scanned", file: jellyfinItem.Path }); + emitSse("log", { name: parsed.name, type: parsed.type, status: "scanned", file: filePath }); } catch (err) { errors++; - logError(`Error scanning ${jellyfinItem.Name}:`, err); + logError(`Error scanning ${filePath}:`, err); try { - db - .prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?") - .run(String(err), jellyfinItem.Id); + db.prepare(` + INSERT INTO media_items (file_path, type, name, scan_status, scan_error, last_scanned_at) + VALUES (?, ?, ?, 'error', ?, datetime('now')) + ON CONFLICT(file_path) DO UPDATE SET scan_status = 'error', scan_error = ?, last_scanned_at = datetime('now') + `).run(filePath, parsed.type, parsed.name, String(err), String(err)); } catch (dbErr) { // Failed to persist the error status — log it so the incident - // doesn't disappear silently. We can't do much more; the outer - // loop continues so the scan still finishes. - logError(`Failed to record scan error for ${jellyfinItem.Id}:`, dbErr); + // doesn't disappear silently. + logError(`Failed to record scan error for ${filePath}:`, dbErr); } - emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "error", file: jellyfinItem.Path }); + emitSse("log", { name: parsed.name, type: parsed.type, status: "error", file: filePath }); } }