rewrite scan: filesystem walk + ffprobe, no jellyfin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+35
-47
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user