clean media stream titles, verify metadata preflight

This commit is contained in:
2026-04-24 09:51:11 +02:00
parent 42189d95bb
commit 3198002836
15 changed files with 426 additions and 115 deletions
+18 -10
View File
@@ -2,20 +2,20 @@ import { unlinkSync } from "node:fs";
import { Hono } from "hono";
import { getAllConfig, getConfig, getDb } from "../db/index";
import { log, error as logError } from "../lib/log";
import { parsePath } from "../services/path-parser";
import { probeFile } from "../services/probe";
import { upsertScannedItem } from "../services/rescan";
import { isOneOf, parseId } from "../lib/validate";
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
import { buildCommand, LANG_NAMES } from "../services/ffmpeg";
import { buildCommand, LANG_NAMES, trackTitle } from "../services/ffmpeg";
import { type LanguageResolverConfig, resolveLanguage } from "../services/language-resolver";
import { normalizeLanguage } from "../services/language-utils";
import { parsePath } from "../services/path-parser";
import { probeFile } from "../services/probe";
import {
loadLibrary as loadRadarrLibrary,
type RadarrLibrary,
isUsable as radarrUsable,
triggerMovieRefetch,
} from "../services/radarr";
import { upsertScannedItem } from "../services/rescan";
import {
loadLibrary as loadSonarrLibrary,
type SonarrLibrary,
@@ -151,9 +151,9 @@ export async function processInbox(
// Also pick up noop items that have never been analyzed with the reasons
// system (reasons IS NULL). Reanalyze may flip them to non-noop if the
// title/language/default checks now catch something the old code missed.
const staleNoops = db
.prepare("SELECT item_id FROM review_plans WHERE is_noop = 1 AND reasons IS NULL")
.all() as { item_id: number }[];
const staleNoops = db.prepare("SELECT item_id FROM review_plans WHERE is_noop = 1 AND reasons IS NULL").all() as {
item_id: number;
}[];
for (const { item_id } of staleNoops) {
reanalyze(db, item_id, audioLanguages);
}
@@ -483,12 +483,13 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
if (!plan) return;
const decisions = db
.prepare(
"SELECT stream_id, action, target_index, custom_language, transcode_codec FROM stream_decisions WHERE plan_id = ?",
"SELECT stream_id, action, target_index, custom_title, custom_language, transcode_codec FROM stream_decisions WHERE plan_id = ?",
)
.all(plan.id) as {
stream_id: number;
action: "keep" | "remove";
target_index: number | null;
custom_title: string | null;
custom_language: string | null;
transcode_codec: string | null;
}[];
@@ -516,12 +517,19 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
const updateIdx = db.prepare("UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?");
for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id);
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode/metadata needed
const anyAudioRemoved = streams.some(
(s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
);
const hasSubs = streams.some((s) => s.type === "Subtitle");
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
const titleMismatch = streams.some((s) => {
if (s.type !== "Video" && s.type !== "Audio") return false;
const d = decisions.find((dec) => dec.stream_id === s.id);
if (d?.action !== "keep") return false;
const expected = d.custom_title ?? trackTitle(s, d.custom_language);
return expected != null && s.title !== expected;
});
const keptAudio = streams
.filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep")
@@ -535,7 +543,7 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
}
}
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode && !titleMismatch;
// Only flip is_noop to 1 when the plan is unsorted (inbox). If the user is
// actively reviewing a sorted plan, marking all tracks "keep" should NOT
+2
View File
@@ -87,6 +87,8 @@ function migrate(db: Database): void {
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)");
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)");
alter("ALTER TABLE review_plans ADD COLUMN reasons TEXT");
alter("ALTER TABLE media_streams ADD COLUMN width INTEGER");
alter("ALTER TABLE media_streams ADD COLUMN height INTEGER");
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
// drop-jellyfin: if old schema detected, wipe all tables so SCHEMA
// recreates them with the new structure (file_path UNIQUE, no jellyfin columns).
+2
View File
@@ -50,6 +50,8 @@ CREATE TABLE IF NOT EXISTS media_streams (
bit_rate INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
width INTEGER,
height INTEGER,
UNIQUE(item_id, stream_index)
);
@@ -21,6 +21,8 @@ function stream(o: StreamOverride): MediaStream {
bit_rate: null,
sample_rate: null,
bit_depth: null,
width: null,
height: null,
...o,
};
}
@@ -428,6 +430,45 @@ describe("analyzeItem — one audio track per language", () => {
});
expect(result.is_noop).toBe(false);
});
test("wrong video title with release/ad noise → not noop", () => {
const streams = [
stream({
id: 1,
type: "Video",
stream_index: 0,
codec: "h264",
width: 1920,
height: 1080,
title: "Movie.Name.1080p.WEB-DL.ADS-GRP",
}),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
audioLanguages: [],
});
expect(result.is_noop).toBe(false);
expect(result.reasons).toContain("Fix titles");
});
test("correct video and audio titles → noop", () => {
const streams = [
stream({
id: 1,
type: "Video",
stream_index: 0,
codec: "h264",
width: 1920,
height: 1080,
title: "1080p - H.264",
}),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
audioLanguages: [],
});
expect(result.is_noop).toBe(true);
});
});
describe("analyzeItem — auto_class classification", () => {
+28 -1
View File
@@ -17,6 +17,8 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
bit_rate: null,
sample_rate: null,
bit_depth: null,
width: null,
height: null,
...o,
};
}
@@ -99,7 +101,7 @@ describe("sortKeptStreams", () => {
describe("buildCommand", () => {
test("produces ffmpeg remux with tmp-rename pattern", () => {
const streams = [
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
];
const decisions = [
@@ -111,6 +113,8 @@ describe("buildCommand", () => {
expect(cmd).toContain("-map 0:v:0");
expect(cmd).toContain("-map 0:a:0");
expect(cmd).toContain("-c copy");
expect(cmd).toContain("-metadata:s:v:0 title='1080p - H.264'");
expect(cmd).toContain("-metadata title=");
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'");
});
@@ -236,6 +240,29 @@ describe("buildCommand", () => {
expect(cmd).toContain("-disposition:a:0 default");
expect(cmd).toContain("-disposition:a:1 0");
});
test("writes canonical video titles without release-group noise", () => {
const streams = [
stream({
id: 1,
type: "Video",
stream_index: 0,
codec: "hevc",
width: 3840,
height: 2160,
title: "Movie Name - 2160p WEB-DL HDR10 - ADS - GRP",
}),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", channels: 6, language: "eng" }),
];
const decisions = [
decision({ stream_id: 1, action: "keep", target_index: 0 }),
decision({ stream_id: 2, action: "keep", target_index: 0 }),
];
const cmd = buildCommand(ITEM, streams, decisions);
expect(cmd).toContain("-metadata:s:v:0 title='2160p - HEVC'");
expect(cmd).not.toContain("ADS");
expect(cmd).not.toContain("GRP");
});
});
describe("buildPipelineCommand", () => {
+6
View File
@@ -45,6 +45,8 @@ describe("parseProbeOutput — video stream", () => {
codec_type: "video",
codec_name: "h264",
profile: "High",
width: 1920,
height: 1080,
disposition: { default: 1, forced: 0, hearing_impaired: 0 },
tags: { language: "eng", title: "Main Video" },
},
@@ -67,6 +69,8 @@ describe("parseProbeOutput — video stream", () => {
expect(s.bitRate).toBeNull();
expect(s.sampleRate).toBeNull();
expect(s.bitDepth).toBeNull();
expect(s.width).toBe(1920);
expect(s.height).toBe(1080);
});
});
@@ -104,6 +108,8 @@ describe("parseProbeOutput — audio stream", () => {
expect(s.bitRate).toBe(640000);
expect(s.sampleRate).toBe(48000);
expect(s.bitDepth).toBe(24);
expect(s.width).toBeNull();
expect(s.height).toBeNull();
expect(s.isDefault).toBe(1);
expect(s.isForced).toBe(0);
expect(s.isHearingImpaired).toBe(0);
+9 -3
View File
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { upsertScannedItem } from "../rescan";
import { describe, expect, test } from "bun:test";
import { SCHEMA } from "../../db/schema";
import type { ProbeResult } from "../probe";
import type { ParsedPath } from "../path-parser";
import type { ProbeResult } from "../probe";
import { upsertScannedItem } from "../rescan";
function freshDb(): Database {
const db = new Database(":memory:");
@@ -44,6 +44,8 @@ const PROBE: ProbeResult = {
bitRate: null,
sampleRate: null,
bitDepth: null,
width: 1920,
height: 1080,
},
{
streamIndex: 1,
@@ -60,6 +62,8 @@ const PROBE: ProbeResult = {
bitRate: 1509000,
sampleRate: 48000,
bitDepth: 24,
width: null,
height: null,
},
],
};
@@ -81,6 +85,8 @@ describe("upsertScannedItem", () => {
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(result.itemId);
expect(streams).toHaveLength(2);
expect((streams[0] as any).width).toBe(1920);
expect((streams[0] as any).height).toBe(1080);
});
test("upserts on same file_path", () => {
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";
import { type ProbedStream, verifyStreamMetadata } from "../verify";
type ExpectedStream = Parameters<typeof verifyStreamMetadata>[0][number];
function expected(
o: Partial<ExpectedStream> & Pick<ExpectedStream, "id" | "stream_id" | "type" | "stream_index">,
): ExpectedStream {
return {
id: o.id,
item_id: 1,
stream_id: o.stream_id,
plan_id: 1,
action: "keep",
target_index: 0,
custom_title: null,
custom_language: null,
transcode_codec: null,
codec: null,
profile: null,
language: null,
title: null,
is_default: 0,
is_forced: 0,
is_hearing_impaired: 0,
channels: null,
channel_layout: null,
bit_rate: null,
sample_rate: null,
bit_depth: null,
width: null,
height: null,
...o,
};
}
function probed(o: Partial<ProbedStream> & Pick<ProbedStream, "type">): ProbedStream {
return {
codec: null,
language: null,
title: null,
isDefault: 0,
...o,
};
}
describe("verifyStreamMetadata", () => {
test("detects dirty video title metadata", () => {
const mismatch = verifyStreamMetadata(
[expected({ id: 1, stream_id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 })],
[probed({ type: "Video", codec: "h264", title: "Movie.Name.1080p.ADS-GRP" })],
);
expect(mismatch?.reason).toContain("video track 0: title");
expect(mismatch?.reason).toContain("1080p - H.264");
});
test("detects dirty audio title metadata", () => {
const mismatch = verifyStreamMetadata(
[
expected({
id: 1,
stream_id: 1,
type: "Audio",
stream_index: 0,
codec: "dts",
language: "eng",
channels: 6,
}),
],
[probed({ type: "Audio", codec: "dts", language: "eng", title: "English DTS ads", isDefault: 1 })],
);
expect(mismatch?.reason).toContain("audio track 0: title");
expect(mismatch?.reason).toContain("ENG - DTS · 5.1");
});
test("detects non-canonical language tags and wrong default disposition", () => {
const languageMismatch = verifyStreamMetadata(
[expected({ id: 1, stream_id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })],
[probed({ type: "Audio", codec: "aac", language: "en", title: "ENG - AAC", isDefault: 1 })],
);
expect(languageMismatch?.reason).toContain("language en ≠ expected eng");
const defaultMismatch = verifyStreamMetadata(
[expected({ id: 1, stream_id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })],
[probed({ type: "Audio", codec: "aac", language: "eng", title: "ENG - AAC", isDefault: 0 })],
);
expect(defaultMismatch?.reason).toContain("default disposition 0 ≠ expected 1");
});
test("returns null when video and audio metadata already match", () => {
const mismatch = verifyStreamMetadata(
[
expected({ id: 1, stream_id: 1, type: "Video", stream_index: 0, codec: "hevc", width: 3840, height: 2160 }),
expected({ id: 2, stream_id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
],
[
probed({ type: "Video", codec: "hevc", title: "2160p - HEVC" }),
probed({ type: "Audio", codec: "eac3", language: "eng", title: "ENG - EAC3 · 5.1", isDefault: 1 }),
],
);
expect(mismatch).toBeNull();
});
});
+10 -7
View File
@@ -113,12 +113,15 @@ export function analyzeItem(
return s.language != null && s.language !== normalizeLanguage(s.language);
});
// Title mismatch: the file's audio title is missing or doesn't match our
// harmonized format (e.g. "Chinese - Dolby Digital - 5.1" or null instead
// of "ZHO - EAC3 · 5.1"). Every kept audio track must have the correct
// canonical title for the file to be in its desired state.
const titleMismatch = keptAudioStreams.some((s) => {
const override = languageOverrides?.get(s.id);
// Title mismatch: every kept video/audio track must have the correct
// canonical title for the file to be in its desired state. This removes
// release-group/ad noise from stream metadata while keeping filename info.
const keptTitleStreams = streams.filter((s) => {
if (s.type !== "Video" && s.type !== "Audio") return false;
return decisions.find((d) => d.stream_id === s.id)?.action === "keep";
});
const titleMismatch = keptTitleStreams.some((s) => {
const override = s.type === "Audio" ? languageOverrides?.get(s.id) : undefined;
const expected = trackTitle(s, override ?? null);
return expected != null && s.title !== expected;
});
@@ -130,7 +133,7 @@ export function analyzeItem(
if (needsTranscode) reasons.push("Transcode");
if (defaultMismatch || nonDefaultHasDefault) reasons.push("Fix default");
if (languageMismatch) reasons.push("Fix language tag");
if (titleMismatch) reasons.push("Fix title");
if (titleMismatch) reasons.push("Fix titles");
const is_noop = reasons.length === 0;
+62 -16
View File
@@ -207,12 +207,29 @@ function formatChannels(n: number | null): string | null {
return `${n}ch`;
}
export function trackTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
if (stream.type === "Subtitle") {
// Subtitles always get a clean language-based title so Jellyfin displays
// "German", "English (Forced)", etc. regardless of the original file title.
// The review UI shows a ⚠ badge when the original title looks like a
// different language, so users can spot and remove mislabeled tracks.
function formatCodec(codec: string | null): string | null {
if (!codec) return null;
const normalized = codec.toLowerCase();
const labels: Record<string, string> = {
h264: "H.264",
avc: "H.264",
hevc: "HEVC",
h265: "HEVC",
av1: "AV1",
vp9: "VP9",
vp8: "VP8",
mpeg2video: "MPEG-2",
vc1: "VC-1",
};
return labels[normalized] ?? codec.toUpperCase();
}
function formatResolution(height: number | null): string | null {
if (!height || height <= 0) return null;
return `${height}p`;
}
export function subtitleTitle(stream: MediaStream): string | null {
if (!stream.language) return null;
const lang = normalizeLanguage(stream.language);
const base = LANG_NAMES[lang] ?? lang.toUpperCase();
@@ -220,6 +237,35 @@ export function trackTitle(stream: MediaStream, customLanguage: string | null =
if (stream.is_hearing_impaired) return `${base} (CC)`;
return base;
}
export function videoTitle(stream: MediaStream): string | null {
const resolutionPart = formatResolution(stream.height);
const codecPart = formatCodec(stream.codec);
if (!resolutionPart) return null;
const parts = [resolutionPart, codecPart].filter((v): v is string => !!v);
return parts.length > 0 ? parts.join(" - ") : null;
}
export function audioTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
const rawLang = customLanguage ?? stream.language;
const lang = rawLang ? normalizeLanguage(rawLang) : null;
const langPart = lang ? lang.toUpperCase() : null;
const codecPart = formatCodec(stream.codec);
const channelsPart = formatChannels(stream.channels);
const tail = [codecPart, channelsPart].filter((v): v is string => !!v).join(" · ");
if (langPart && tail) return `${langPart} - ${tail}`;
if (langPart) return langPart;
if (tail) return tail;
return null;
}
export function trackTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
if (stream.type === "Subtitle") {
// Subtitles always get a clean language-based title so Jellyfin displays
// "German", "English (Forced)", etc. regardless of the original file title.
return subtitleTitle(stream);
}
if (stream.type === "Video") return videoTitle(stream);
// Audio: harmonize to "ENG - AC3 · 5.1". Overrides whatever the file had
// (e.g. "Audio Description", "Director's Commentary") — the user uses
// the review UI to drop unwanted tracks before we get here, so by this
@@ -228,15 +274,7 @@ export function trackTitle(stream: MediaStream, customLanguage: string | null =
// the decision still wins (see buildStreamFlags). A per-stream language
// override comes through as customLanguage so "UND → Spanish" renames
// flow through to the harmonized title too.
const rawLang = customLanguage ?? stream.language;
const lang = rawLang ? normalizeLanguage(rawLang) : null;
const langPart = lang ? lang.toUpperCase() : null;
const codecPart = stream.codec ? stream.codec.toUpperCase() : null;
const channelsPart = formatChannels(stream.channels);
const tail = [codecPart, channelsPart].filter((v): v is string => !!v).join(" · ");
if (langPart && tail) return `${langPart} - ${tail}`;
if (langPart) return langPart;
if (tail) return tail;
if (stream.type === "Audio") return audioTitle(stream, customLanguage);
return null;
}
@@ -275,13 +313,19 @@ function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec:
* "ger" → "deu"). Streams with no language get "und" (ffmpeg convention).
*/
function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
const videoKept = kept.filter((k) => k.stream.type === "Video");
const audioKept = kept.filter((k) => k.stream.type === "Audio");
const args: string[] = [];
videoKept.forEach((k, i) => {
const title = k.dec.custom_title ?? videoTitle(k.stream);
if (title) args.push(`-metadata:s:v:${i}`, `title=${shellQuote(title)}`);
});
audioKept.forEach((k, i) => {
args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0");
const title = k.dec.custom_title ?? trackTitle(k.stream, k.dec.custom_language);
const title = k.dec.custom_title ?? audioTitle(k.stream, k.dec.custom_language);
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
// Per-stream language override wins over the raw file tag so the
@@ -291,6 +335,8 @@ function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]):
args.push(`-metadata:s:a:${i}`, `language=${lang}`);
});
args.push("-metadata", "title=");
return args;
}
+6 -5
View File
@@ -13,6 +13,8 @@ export interface ProbeStream {
bitRate: number | null;
sampleRate: number | null;
bitDepth: number | null;
width: number | null;
height: number | null;
}
export interface ProbeResult {
@@ -60,9 +62,7 @@ export function parseProbeOutput(json: string): ProbeResult {
const durationSeconds = parseFloatOrNull(fmt.duration ?? null);
const container = fmt.format_name ? (fmt.format_name as string).split(",")[0] || null : null;
const streams: ProbeStream[] = (raw.streams ?? []).map(
// biome-ignore lint/suspicious/noExplicitAny: raw ffprobe JSON
(s: any): ProbeStream => {
const streams: ProbeStream[] = (raw.streams ?? []).map((s: any): ProbeStream => {
const tags = s.tags ?? {};
const disp = s.disposition ?? {};
return {
@@ -80,9 +80,10 @@ export function parseProbeOutput(json: string): ProbeResult {
bitRate: parseIntOrNull(s.bit_rate ?? null),
sampleRate: parseIntOrNull(s.sample_rate ?? null),
bitDepth: parseIntOrNull(s.bits_per_raw_sample ?? null),
width: parseIntOrNull(s.width ?? null),
height: parseIntOrNull(s.height ?? null),
};
},
);
});
return { fileSize, durationSeconds, container, streams };
}
+5 -2
View File
@@ -93,8 +93,9 @@ export function upsertScannedItem(
INSERT INTO media_streams (
item_id, stream_index, type, codec, profile, language, title,
is_default, is_forced, is_hearing_impaired,
channels, channel_layout, bit_rate, sample_rate, bit_depth
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
channels, channel_layout, bit_rate, sample_rate, bit_depth,
width, height
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const s of probe.streams) {
ins.run(
@@ -113,6 +114,8 @@ export function upsertScannedItem(
s.bitRate,
s.sampleRate,
s.bitDepth,
s.width,
s.height,
);
}
+109 -51
View File
@@ -1,9 +1,14 @@
import type { Database } from "bun:sqlite";
import type { MediaStream, StreamDecision } from "../types";
import { trackTitle } from "./ffmpeg";
import { normalizeLanguage } from "./language-utils";
interface ProbedStream {
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[]> {
@@ -16,12 +21,19 @@ async function ffprobeStreams(filePath: string): Promise<ProbedStream[]> {
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 } }>;
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 ?? null,
language: s.tags?.language ?? s.tags?.LANGUAGE ?? null,
title: s.tags?.title ?? s.tags?.TITLE ?? null,
isDefault: s.disposition?.default ?? 0,
}));
}
@@ -47,65 +59,52 @@ export interface VerifyResult {
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<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 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}` };
}
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 probedSubs = probed.filter((s) => s.type === "Subtitle");
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 (probedSubs.length > 0) {
return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` };
}
if (probedAudio.length !== expected.length) {
if (probedVideo.length !== expectedVideo.length) {
return {
matches: false,
reason: `audio stream count mismatch (file: ${probedAudio.length}, expected: ${expected.length})`,
reason: `video stream count mismatch (file: ${probedVideo.length}, expected: ${expectedVideo.length})`,
};
}
for (let i = 0; i < expected.length; i++) {
const want = expected[i];
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 wantLang = (want.language ?? "").toLowerCase();
const expectedLanguage = want.custom_language ?? want.language;
const wantLang = expectedLanguage ? normalizeLanguage(expectedLanguage) : "und";
const gotLang = (got.language ?? "").toLowerCase();
if (wantLang && wantLang !== gotLang) {
if (wantLang !== gotLang) {
return {
matches: false,
reason: `audio track ${i}: language ${gotLang || "<none>"} ≠ expected ${wantLang}`,
@@ -117,7 +116,66 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath:
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
@@ -134,6 +192,6 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath:
return {
matches: true,
reason: `file already matches desired layout (${probedAudio.length} audio track(s), no embedded subtitles)`,
reason: `file already matches desired layout (${expected.filter((s) => s.type === "Audio").length} audio track(s), no embedded subtitles)`,
};
}
+2
View File
@@ -43,6 +43,8 @@ export interface MediaStream {
bit_rate: number | null;
sample_rate: number | null;
bit_depth: number | null;
width: number | null;
height: number | null;
}
export interface ReviewPlan {
+3
View File
@@ -38,6 +38,9 @@ export interface MediaStream {
channel_layout: string | null;
bit_rate: number | null;
sample_rate: number | null;
bit_depth: number | null;
width: number | null;
height: number | null;
}
export interface ReviewPlan {