rip out jellyfin handoff verification path and verify-unverified endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 06:58:55 +02:00
parent cbf0025a81
commit 0d6781973b

View File

@@ -1,12 +1,9 @@
import { accessSync, constants } from "node:fs";
import { Hono } from "hono";
import { stream } from "hono/streaming";
import { getAllConfig, getDb } from "../db/index";
import { getDb } from "../db/index";
import { log, error as logError, warn } from "../lib/log";
import { predictExtractedFiles } from "../services/ffmpeg";
import { getItem, refreshItem } from "../services/jellyfin";
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { type RescanConfig, upsertJellyfinItem } from "../services/rescan";
import {
getScheduleConfig,
isInProcessWindow,
@@ -15,88 +12,9 @@ import {
sleepBetweenJobs,
waitForProcessWindow,
} from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
import { verifyDesiredState } from "../services/verify";
import type { Job, MediaItem, MediaStream } from "../types";
/**
* Post-job hand-off to Jellyfin. Three phases:
* 1. refreshItem — ask Jellyfin to re-probe the file on disk and wait
* until its DateLastRefreshed advances (or 15s timeout).
* 2. getItem — pull back the freshly-probed metadata.
* 3. upsertJellyfinItem(source='webhook') — re-run the analyzer against
* Jellyfin's view. If it matches the plan (is_noop=1), sets verified=1
* — the ✓✓ in the Done column. If Jellyfin sees a different layout
* (is_noop=0) the plan flips back to 'pending' so the user notices.
*
* This closes the previously-dangling "we asked Jellyfin to refresh but
* never checked what it saw" gap. Earlier attempt used our own ffprobe of
* the output, but that was tautological — ffmpeg just wrote the file to
* match the plan, so the check always passed immediately. Jellyfin is the
* independent observer that matters.
*/
export async function handOffToJellyfin(itemId: number): Promise<void> {
const db = getDb();
const row = db.prepare("SELECT jellyfin_id FROM media_items WHERE id = ?").get(itemId) as
| { jellyfin_id: string }
| undefined;
if (!row) return;
const cfg = getAllConfig();
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
if (!jellyfinCfg.url || !jellyfinCfg.apiKey) return;
let refreshResult: { refreshed: boolean };
try {
refreshResult = await refreshItem(jellyfinCfg, row.jellyfin_id);
} catch (err) {
warn(`Jellyfin refresh for item ${itemId} failed: ${String(err)} — skipping verification`);
return;
}
if (!refreshResult.refreshed) {
// DateLastRefreshed never advanced within the timeout — Jellyfin may
// still be probing asynchronously. We can't trust the item data we'd
// fetch right now, so skip the verify step; the plan stays verified=0
// (single ✓) rather than risk flipping it based on stale metadata.
warn(`Jellyfin refresh for item ${itemId} timed out — leaving plan unverified`);
return;
}
try {
const fresh = await getItem(jellyfinCfg, row.jellyfin_id);
if (!fresh) {
warn(`Jellyfin returned no item for ${row.jellyfin_id} during verification`);
return;
}
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
const [radarrLibrary, sonarrLibrary] = await Promise.all([
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
]);
const audioLanguages = JSON.parse(cfg.audio_languages || "[]") as string[];
const rescanCfg: RescanConfig = {
audioLanguages,
radarr: radarrEnabled ? radarrCfg : null,
sonarr: sonarrEnabled ? sonarrCfg : null,
radarrLibrary,
sonarrLibrary,
};
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
log(`Post-job verify for item ${itemId}: is_noop=${result.isNoop}`);
// Nudge connected clients so the Done column re-polls and promotes
// the card from ✓ to ✓✓ (or flips it back to Review if jellyfin
// disagreed).
emitPlanUpdate(itemId);
} catch (err) {
warn(`Post-job verification for item ${itemId} failed: ${String(err)}`);
}
}
const app = new Hono();
// ─── Sequential local queue ──────────────────────────────────────────────────
@@ -173,18 +91,6 @@ function emitJobProgress(jobId: number, seconds: number, total: number): void {
for (const l of jobListeners) l(line);
}
/**
* Emit when a review_plan mutates asynchronously (after the job already
* finished). Right now the only producer is handOffToJellyfin — the
* verified=1 write that lands ~15s after a job completes. Without this
* the UI would keep showing ✓ indefinitely until the user navigates
* away and back, since we'd never fire job_update again for that item.
*/
function emitPlanUpdate(itemId: number): void {
const line = `event: plan_update\ndata: ${JSON.stringify({ itemId })}\n\n`;
for (const l of jobListeners) l(line);
}
/** Parse "Duration: HH:MM:SS.MS" from ffmpeg startup output. */
function parseFFmpegDuration(line: string): number | null {
const match = line.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
@@ -231,62 +137,6 @@ function loadJobRow(jobId: number) {
return { job: row as unknown as Job, item };
}
// ─── List ─────────────────────────────────────────────────────────────────────
app.get("/", (c) => {
const db = getDb();
const filter = (c.req.query("filter") ?? "pending") as "all" | "pending" | "running" | "done" | "error";
const validFilters = ["all", "pending", "running", "done", "error"];
const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
const params = whereClause ? [filter] : [];
const jobRows = db
.prepare(`
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id
${whereClause}
ORDER BY j.created_at DESC
LIMIT 200
`)
.all(...params) as (Job & {
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
file_path: string;
})[];
const jobs = jobRows.map((r) => ({
job: r as unknown as Job,
item: r.name
? ({
id: r.item_id,
name: r.name,
type: r.type,
series_name: r.series_name,
season_number: r.season_number,
episode_number: r.episode_number,
file_path: r.file_path,
} as unknown as MediaItem)
: null,
}));
const countRows = db.prepare("SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status").all() as {
status: string;
cnt: number;
}[];
const totalCounts: Record<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 };
for (const row of countRows) {
totalCounts[row.status] = row.cnt;
totalCounts.all += row.cnt;
}
return c.json({ jobs, filter, totalCounts });
});
// ─── Param helpers ────────────────────────────────────────────────────────────
function parseId(raw: string | undefined): number | null {
@@ -354,40 +204,6 @@ app.post("/clear-completed", (c) => {
return c.json({ ok: true, cleared: result.changes });
});
// ─── Verify all unverified done plans ────────────────────────────────────────
// Backfill: kicks off the post-job jellyfin handoff for every plan that's
// status=done + verified=0. Sequential with a small inter-call delay to
// avoid hammering jellyfin's metadata refresher (each one waits up to 15s
// for DateLastRefreshed to advance). Returns immediately with the count;
// each individual handoff emits a plan_update SSE so the UI promotes ✓ → ✓✓
// (or flips back to Review on disagreement) as it lands.
app.post("/verify-unverified", (c) => {
const db = getDb();
const rows = db
.prepare(`
SELECT mi.id as item_id FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'done' AND rp.verified = 0
ORDER BY rp.reviewed_at DESC NULLS LAST
`)
.all() as { item_id: number }[];
if (rows.length === 0) return c.json({ ok: true, count: 0 });
(async () => {
for (const row of rows) {
try {
await handOffToJellyfin(row.item_id);
} catch (err) {
warn(`verify-unverified: handoff for item ${row.item_id} threw: ${String(err)}`);
}
}
log(`verify-unverified: processed ${rows.length} unverified done plan(s)`);
})();
return c.json({ ok: true, count: rows.length });
});
// ─── Stop running job ─────────────────────────────────────────────────────────
app.post("/stop", (c) => {
@@ -481,17 +297,9 @@ async function runJob(job: Job): Promise<void> {
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?",
)
.run(msg, job.id);
// Preflight matched → file is already correct per our own ffprobe.
// We still hand off to Jellyfin below so its independent re-probe
// drives the ✓✓ verified flag, rather than trusting our check of
// our own output.
db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?").run(job.item_id);
})();
emitJobUpdate(job.id, "done", msg);
// Hand off so Jellyfin re-probes and can corroborate the ✓✓.
handOffToJellyfin(job.item_id).catch((err) =>
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
);
return;
}
log(`Job ${job.id} preflight: ${verify.reason} — running FFmpeg`);
@@ -599,16 +407,6 @@ async function runJob(job: Job): Promise<void> {
log(`Job ${job.id} completed successfully`);
emitJobUpdate(job.id, "done", fullOutput);
// Fire-and-forget hand-off. Jellyfin re-probes the file we just wrote,
// we wait for DateLastRefreshed to advance, then re-analyze its fresh
// view. Setting verified=1 only happens when Jellyfin's independent
// probe confirms is_noop=1. If its view disagrees the plan flips back
// to 'pending' so the user notices — better than silently rubber-
// stamping a bad output as ✓✓.
handOffToJellyfin(job.item_id).catch((err) =>
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
);
} catch (err) {
logError(`Job ${job.id} failed:`, err);
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;