From 31980028367c69d635d0c1fcfbbf12f6035cac35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 24 Apr 2026 09:51:11 +0200 Subject: [PATCH] clean media stream titles, verify metadata preflight --- server/api/review.ts | 28 ++-- server/db/index.ts | 2 + server/db/schema.ts | 2 + server/services/__tests__/analyzer.test.ts | 41 ++++++ server/services/__tests__/ffmpeg.test.ts | 29 +++- server/services/__tests__/probe.test.ts | 6 + server/services/__tests__/rescan.test.ts | 12 +- server/services/__tests__/verify.test.ts | 103 +++++++++++++ server/services/analyzer.ts | 17 ++- server/services/ffmpeg.ts | 82 ++++++++--- server/services/probe.ts | 47 +++--- server/services/rescan.ts | 7 +- server/services/verify.ts | 160 ++++++++++++++------- server/types.ts | 2 + src/shared/lib/types.ts | 3 + 15 files changed, 426 insertions(+), 115 deletions(-) create mode 100644 server/services/__tests__/verify.test.ts diff --git a/server/api/review.ts b/server/api/review.ts index 8d625fe..f9d5ccf 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -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, 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, 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, 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 diff --git a/server/db/index.ts b/server/db/index.ts index 37c781e..e690ecc 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -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). diff --git a/server/db/schema.ts b/server/db/schema.ts index 9f9e443..8e937fd 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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) ); diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index 4d56f46..fe64bc9 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -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", () => { diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index 08a4c56..0117cb8 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -17,6 +17,8 @@ function stream(o: Partial & Pick { 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", () => { diff --git a/server/services/__tests__/probe.test.ts b/server/services/__tests__/probe.test.ts index ff69f0e..7de851f 100644 --- a/server/services/__tests__/probe.test.ts +++ b/server/services/__tests__/probe.test.ts @@ -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); diff --git a/server/services/__tests__/rescan.test.ts b/server/services/__tests__/rescan.test.ts index ee5a250..4d37306 100644 --- a/server/services/__tests__/rescan.test.ts +++ b/server/services/__tests__/rescan.test.ts @@ -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", () => { diff --git a/server/services/__tests__/verify.test.ts b/server/services/__tests__/verify.test.ts new file mode 100644 index 0000000..da00db4 --- /dev/null +++ b/server/services/__tests__/verify.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { type ProbedStream, verifyStreamMetadata } from "../verify"; + +type ExpectedStream = Parameters[0][number]; + +function expected( + o: Partial & Pick, +): 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 & Pick): 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(); + }); +}); diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index 4205341..cec1916 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -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; diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 90f0e06..f5f6f19 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -207,19 +207,65 @@ function formatChannels(n: number | null): string | null { return `${n}ch`; } +function formatCodec(codec: string | null): string | null { + if (!codec) return null; + const normalized = codec.toLowerCase(); + const labels: Record = { + 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(); + if (stream.is_forced) return `${base} (Forced)`; + 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. - // The review UI shows a ⚠ badge when the original title looks like a - // different language, so users can spot and remove mislabeled tracks. - if (!stream.language) return null; - const lang = normalizeLanguage(stream.language); - const base = LANG_NAMES[lang] ?? lang.toUpperCase(); - if (stream.is_forced) return `${base} (Forced)`; - if (stream.is_hearing_impaired) return `${base} (CC)`; - return base; + 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; } diff --git a/server/services/probe.ts b/server/services/probe.ts index 01a309d..ea470ac 100644 --- a/server/services/probe.ts +++ b/server/services/probe.ts @@ -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,29 +62,28 @@ 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 tags = s.tags ?? {}; - const disp = s.disposition ?? {}; - return { - streamIndex: s.index as number, - type: mapCodecType(s.codec_type ?? ""), - codec: (s.codec_name as string | undefined) ?? null, - profile: (s.profile as string | undefined) ?? null, - language: (tags.language ?? tags.LANGUAGE ?? null) as string | null, - title: (tags.title ?? tags.TITLE ?? null) as string | null, - isDefault: (disp.default as number | undefined) ?? 0, - isForced: (disp.forced as number | undefined) ?? 0, - isHearingImpaired: (disp.hearing_impaired as number | undefined) ?? 0, - channels: parseIntOrNull(s.channels ?? null), - channelLayout: (s.channel_layout as string | undefined) ?? null, - bitRate: parseIntOrNull(s.bit_rate ?? null), - sampleRate: parseIntOrNull(s.sample_rate ?? null), - bitDepth: parseIntOrNull(s.bits_per_raw_sample ?? null), - }; - }, - ); + const streams: ProbeStream[] = (raw.streams ?? []).map((s: any): ProbeStream => { + const tags = s.tags ?? {}; + const disp = s.disposition ?? {}; + return { + streamIndex: s.index as number, + type: mapCodecType(s.codec_type ?? ""), + codec: (s.codec_name as string | undefined) ?? null, + profile: (s.profile as string | undefined) ?? null, + language: (tags.language ?? tags.LANGUAGE ?? null) as string | null, + title: (tags.title ?? tags.TITLE ?? null) as string | null, + isDefault: (disp.default as number | undefined) ?? 0, + isForced: (disp.forced as number | undefined) ?? 0, + isHearingImpaired: (disp.hearing_impaired as number | undefined) ?? 0, + channels: parseIntOrNull(s.channels ?? null), + channelLayout: (s.channel_layout as string | undefined) ?? null, + 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 }; } diff --git a/server/services/rescan.ts b/server/services/rescan.ts index ebbe21c..f705d11 100644 --- a/server/services/rescan.ts +++ b/server/services/rescan.ts @@ -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, ); } diff --git a/server/services/verify.ts b/server/services/verify.ts index eab7322..4d13531 100644 --- a/server/services/verify.ts +++ b/server/services/verify.ts @@ -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 { @@ -16,12 +21,19 @@ async function ffprobeStreams(filePath: string): Promise { if (exitCode !== 0) throw new Error(`ffprobe exited ${exitCode}: ${stderr.trim() || ""}`); 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 { - 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 || ""} ≠ 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 || ""} ≠ expected ${wantLang}`, @@ -117,8 +116,67 @@ 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 || ""} ≠ 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 { + 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(` @@ -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)`, }; } diff --git a/server/types.ts b/server/types.ts index 3b7e75f..578cb06 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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 { diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 00edef5..4f21f5b 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -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 {