198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
import type { MediaStream, StreamDecision } from "../types";
|
|
import { 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;
|
|
}
|
|
|
|
async function ffprobeStreams(filePath: string): Promise<ProbedStream[]> {
|
|
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() || "<no stderr>"}`);
|
|
|
|
const data = JSON.parse(stdout) as {
|
|
streams?: Array<{
|
|
codec_type?: string;
|
|
codec_name?: string;
|
|
tags?: { language?: string; LANGUAGE?: string; title?: string; TITLE?: string };
|
|
disposition?: { default?: number };
|
|
}>;
|
|
};
|
|
return (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,
|
|
}));
|
|
}
|
|
|
|
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 || "<none>"} ≠ 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 || "<none>"} ≠ 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 || "<none>"} ≠ 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<VerifyResult> {
|
|
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 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: ProbedStream[];
|
|
try {
|
|
probed = await ffprobeStreams(filePath);
|
|
} catch (err) {
|
|
return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` };
|
|
}
|
|
|
|
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` };
|
|
}
|
|
|
|
const mismatch = verifyStreamMetadata(expected, probed);
|
|
if (mismatch) return mismatch;
|
|
|
|
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)`,
|
|
};
|
|
}
|