rewrite scan: filesystem walk + ffprobe, no jellyfin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:12:31 +02:00
parent fbfd492e18
commit 8a95026728
+35 -47
View File
@@ -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<void> {
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<void> {
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 });
}
}