Files
netfelix-audio-fix/server/services/verify.ts
T
felixfoertsch 748145a372
Build and Push Docker Image / build (push) Successful in 3m57s
detect dirty container title and comment, rewrite to canonical form
Track format.tags.title and format.tags.comment on media_items via a new
containerTitle() helper producing "Name (Year)" for movies and
"Series (Year) - S01E02 - Title" for episodes. Analyzer and
recomputePlanAfterToggle now flag non-canonical container title and
non-empty comment as non-noop ("Fix container title", "Clear comment"),
and verifyDesiredState checks them post-ffmpeg. buildStreamFlags writes
the canonical title and clears comment on every run.

Existing libraries need a rescan to populate the new columns.
2026-04-24 21:45:39 +02:00

224 lines
7.5 KiB
TypeScript

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<ProbedFile> {
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() || "<no stderr>"}`);
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 || "<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 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 || "<none>"} ≠ expected ${expectedContainerTitle ?? "<none>"}`,
};
}
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)`,
};
}