748145a372
Build and Push Docker Image / build (push) Successful in 3m57s
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.
224 lines
7.5 KiB
TypeScript
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)`,
|
|
};
|
|
}
|