diff --git a/server/api/execute.ts b/server/api/execute.ts index 22c055f..3146e7c 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -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 { - 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 = { 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 { "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 { 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)}`;