import type { Database } from "bun:sqlite"; interface ProbedStream { type: "Audio" | "Video" | "Subtitle" | "Data" | "Attachment" | "Unknown"; codec: string | null; language: string | null; } async function ffprobeStreams(filePath: string): Promise { const proc = Bun.spawn(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", filePath], { stdout: "pipe", stderr: "pipe", }); const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const exitCode = await proc.exited; if (exitCode !== 0) throw new Error(`ffprobe exited ${exitCode}: ${stderr.trim() || ""}`); const data = JSON.parse(stdout) as { streams?: Array<{ codec_type?: string; codec_name?: string; tags?: { language?: string } }>; }; return (data.streams ?? []).map((s) => ({ type: codecTypeToType(s.codec_type), codec: s.codec_name ?? null, language: s.tags?.language ?? null, })); } function codecTypeToType(t: string | undefined): ProbedStream["type"] { switch (t) { case "audio": return "Audio"; case "video": return "Video"; case "subtitle": return "Subtitle"; case "data": return "Data"; case "attachment": return "Attachment"; default: return "Unknown"; } } export interface VerifyResult { matches: boolean; reason: string; } /** * Check whether the on-disk file already matches the plan's desired state. * Comparison is conservative: any uncertainty falls back to "run the job". * * Matches when: * - audio stream count, language order, and codec match the `keep` decisions * - no subtitle streams remain in the container * - either subs_extracted=1 or the plan has no subtitle decisions to extract */ export async function verifyDesiredState(db: Database, itemId: number, filePath: string): Promise { const plan = db.prepare("SELECT id, subs_extracted FROM review_plans WHERE item_id = ?").get(itemId) as | { id: number; subs_extracted: number } | undefined; if (!plan) return { matches: false, reason: "no review plan found" }; const expected = db .prepare(` SELECT sd.target_index, sd.transcode_codec, ms.language, ms.codec FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND sd.action = 'keep' AND ms.type = 'Audio' ORDER BY sd.target_index `) .all(plan.id) as { target_index: number; transcode_codec: string | null; language: string | null; codec: string | null; }[]; let probed: ProbedStream[]; try { probed = await ffprobeStreams(filePath); } catch (err) { return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` }; } const probedAudio = probed.filter((s) => s.type === "Audio"); const probedSubs = probed.filter((s) => s.type === "Subtitle"); if (probedSubs.length > 0) { return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` }; } if (probedAudio.length !== expected.length) { return { matches: false, reason: `audio stream count mismatch (file: ${probedAudio.length}, expected: ${expected.length})`, }; } for (let i = 0; i < expected.length; i++) { const want = expected[i]; const got = probedAudio[i]; const wantCodec = (want.transcode_codec ?? want.codec ?? "").toLowerCase(); const gotCodec = (got.codec ?? "").toLowerCase(); const wantLang = (want.language ?? "").toLowerCase(); const gotLang = (got.language ?? "").toLowerCase(); if (wantLang && wantLang !== gotLang) { return { matches: false, reason: `audio track ${i}: language ${gotLang || ""} ≠ expected ${wantLang}`, }; } if (wantCodec && gotCodec && wantCodec !== gotCodec) { return { matches: false, reason: `audio track ${i}: codec ${gotCodec} ≠ expected ${wantCodec}`, }; } } if (plan.subs_extracted === 0) { const pendingSubs = db .prepare(` SELECT COUNT(*) as n FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle' `) .get(plan.id) as { n: number }; if (pendingSubs.n > 0) { return { matches: false, reason: "subtitles not yet extracted to sidecar files" }; } } return { matches: true, reason: `file already matches desired layout (${probedAudio.length} audio track(s), no embedded subtitles)`, }; }