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:
@@ -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)}`;
|
||||
|
||||
Reference in New Issue
Block a user