clean media stream titles, verify metadata preflight
This commit is contained in:
+18
-10
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user