import type { Database } from "bun:sqlite"; import type { MediaItem, MediaStream, StreamDecision } from "../types"; import { containerTitle, trackTitle } from "./ffmpeg"; import { normalizeLanguage } from "./language-utils"; export interface ProbedStream { type: "Audio" | "Video" | "Subtitle" | "Data" | "Attachment" | "Unknown"; codec: string | null; language: string | null; title: string | null; isDefault: number; } export interface ProbedFile { streams: ProbedStream[]; containerTitle: string | null; containerComment: string | null; } async function ffprobeFile(filePath: string): Promise { const proc = Bun.spawn( ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-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 { format?: { tags?: { title?: string; TITLE?: string; comment?: string; COMMENT?: string } }; streams?: Array<{ codec_type?: string; codec_name?: string; tags?: { language?: string; LANGUAGE?: string; title?: string; TITLE?: string }; disposition?: { default?: number }; }>; }; const fmtTags = data.format?.tags ?? {}; return { streams: (data.streams ?? []).map((s) => ({ type: codecTypeToType(s.codec_type), codec: s.codec_name ?? null, language: s.tags?.language ?? s.tags?.LANGUAGE ?? null, title: s.tags?.title ?? s.tags?.TITLE ?? null, isDefault: s.disposition?.default ?? 0, })), containerTitle: fmtTags.title ?? fmtTags.TITLE ?? null, containerComment: fmtTags.comment ?? fmtTags.COMMENT ?? 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; } type ExpectedKeptStream = MediaStream & StreamDecision & { decision_id?: number; }; export function verifyStreamMetadata(expected: ExpectedKeptStream[], probed: ProbedStream[]): VerifyResult | null { const probedAudio = probed.filter((s) => s.type === "Audio"); const probedVideo = probed.filter((s) => s.type === "Video"); const expectedAudio = expected.filter((s) => s.type === "Audio"); const expectedVideo = expected.filter((s) => s.type === "Video"); if (probedVideo.length !== expectedVideo.length) { return { matches: false, reason: `video stream count mismatch (file: ${probedVideo.length}, expected: ${expectedVideo.length})`, }; } if (probedAudio.length !== expectedAudio.length) { return { matches: false, reason: `audio stream count mismatch (file: ${probedAudio.length}, expected: ${expectedAudio.length})`, }; } for (let i = 0; i < expectedVideo.length; i++) { const want = expectedVideo[i]; const got = probedVideo[i]; const expectedTitle = want.custom_title ?? trackTitle(want, null); if (expectedTitle != null && got.title !== expectedTitle) { return { matches: false, reason: `video track ${i}: title ${got.title || ""} ≠ expected ${expectedTitle}`, }; } } for (let i = 0; i < expectedAudio.length; i++) { const want = expectedAudio[i]; const got = probedAudio[i]; const wantCodec = (want.transcode_codec ?? want.codec ?? "").toLowerCase(); const gotCodec = (got.codec ?? "").toLowerCase(); const expectedLanguage = want.custom_language ?? want.language; const wantLang = expectedLanguage ? normalizeLanguage(expectedLanguage) : "und"; const gotLang = (got.language ?? "").toLowerCase(); if (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}`, }; } const expectedTitle = want.custom_title ?? trackTitle(want, want.custom_language); if (expectedTitle != null && got.title !== expectedTitle) { return { matches: false, reason: `audio track ${i}: title ${got.title || ""} ≠ expected ${expectedTitle}`, }; } const expectedDefault = i === 0 ? 1 : 0; if (got.isDefault !== expectedDefault) { return { matches: false, reason: `audio track ${i}: default disposition ${got.isDefault} ≠ expected ${expectedDefault}`, }; } } return null; } /** * 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: * - video/audio stream count, metadata, 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 item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; if (!item) return { matches: false, reason: "no media item found" }; const expected = db .prepare(` SELECT ms.*, sd.id as decision_id, sd.plan_id, sd.stream_id, sd.action, sd.target_index, sd.custom_title, sd.custom_language, sd.transcode_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 IN ('Video', 'Audio') ORDER BY CASE ms.type WHEN 'Video' THEN 0 WHEN 'Audio' THEN 1 ELSE 2 END, sd.target_index `) .all(plan.id) as ExpectedKeptStream[]; let probed: ProbedFile; try { probed = await ffprobeFile(filePath); } catch (err) { return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` }; } const probedSubs = probed.streams.filter((s) => s.type === "Subtitle"); if (probedSubs.length > 0) { return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` }; } const mismatch = verifyStreamMetadata(expected, probed.streams); if (mismatch) return mismatch; const expectedContainerTitle = containerTitle(item); if ((probed.containerTitle ?? null) !== (expectedContainerTitle ?? null)) { return { matches: false, reason: `container title ${probed.containerTitle || ""} ≠ expected ${expectedContainerTitle ?? ""}`, }; } if (probed.containerComment && probed.containerComment.length > 0) { return { matches: false, reason: `container comment not empty: ${probed.containerComment}` }; } 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 (${expected.filter((s) => s.type === "Audio").length} audio track(s), no embedded subtitles)`, }; }