diff --git a/package.json b/package.json index 708a988..f0c6a9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.24", + "version": "2026.04.24.1", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/review.ts b/server/api/review.ts index f9d5ccf..befd584 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -4,7 +4,7 @@ import { getAllConfig, getConfig, getDb } from "../db/index"; import { log, error as logError } from "../lib/log"; import { isOneOf, parseId } from "../lib/validate"; import { analyzeItem, assignTargetOrder } from "../services/analyzer"; -import { buildCommand, LANG_NAMES, trackTitle } from "../services/ffmpeg"; +import { buildCommand, containerTitle, 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"; @@ -404,12 +404,7 @@ export function reanalyze( } const analysis = analyzeItem( - { - original_language: item.original_language, - orig_lang_source: item.orig_lang_source, - needs_review: item.needs_review, - container: item.container, - }, + item, streams, { audioLanguages }, languageOverrides.size > 0 ? languageOverrides : undefined, @@ -530,6 +525,9 @@ function recomputePlanAfterToggle(db: ReturnType, itemId: number): const expected = d.custom_title ?? trackTitle(s, d.custom_language); return expected != null && s.title !== expected; }); + const expectedContainerTitle = containerTitle(item); + const containerTitleMismatch = (item.container_title ?? null) !== (expectedContainerTitle ?? null); + const containerCommentDirty = !!item.container_comment && item.container_comment.length > 0; const keptAudio = streams .filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep") @@ -543,7 +541,14 @@ function recomputePlanAfterToggle(db: ReturnType, itemId: number): } } - const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode && !titleMismatch; + const isNoop = + !anyAudioRemoved && + !audioOrderChanged && + !hasSubs && + !needsTranscode && + !titleMismatch && + !containerTitleMismatch && + !containerCommentDirty; // 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 e690ecc..9b64ede 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -89,6 +89,8 @@ function migrate(db: Database): void { 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"); + alter("ALTER TABLE media_items ADD COLUMN container_title TEXT"); + alter("ALTER TABLE media_items ADD COLUMN container_comment TEXT"); // 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 8e937fd..db76f69 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -26,6 +26,8 @@ CREATE TABLE IF NOT EXISTS media_items ( imdb_id TEXT, tmdb_id TEXT, tvdb_id TEXT, + container_title TEXT, + container_comment TEXT, scan_status TEXT NOT NULL DEFAULT 'pending', scan_error TEXT, last_scanned_at TEXT, diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index 62ef678..43bf204 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -31,6 +31,17 @@ const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null, orig_lang_source: null as OrigLangSource, + // Default to a clean movie with matching canonical container title so + // existing noop tests stay noop. Tests that care about container title + // detection override these explicitly. + type: "Movie" as "Movie" | "Episode", + name: "Test" as string, + year: null as number | null, + series_name: null as string | null, + season_number: null as number | null, + episode_number: null as number | null, + container_title: "Test" as string | null, + container_comment: null as string | null, }; describe("analyzeItem — audio keep rules", () => { @@ -470,6 +481,69 @@ describe("analyzeItem — one audio track per language", () => { }); expect(result.is_noop).toBe(true); }); + + test("dirty container title with release-group junk → not 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, + type: "Movie", + name: "101 Dalmatians", + year: 1961, + container_title: "101.Dalmatians.1961.1080p.BluRay.x264-RARBG", + original_language: "eng", + }, + streams, + { audioLanguages: [] }, + ); + expect(result.is_noop).toBe(false); + expect(result.reasons).toContain("Fix container title"); + }); + + test("container title matching canonical form → 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, + type: "Movie", + name: "101 Dalmatians", + year: 1961, + container_title: "101 Dalmatians (1961)", + original_language: "eng", + }, + streams, + { audioLanguages: [] }, + ); + expect(result.is_noop).toBe(true); + }); + + test("non-empty container comment → not noop with reason Clear comment", () => { + 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, + type: "Movie", + name: "101 Dalmatians", + year: 1961, + container_title: "101 Dalmatians (1961)", + container_comment: "rarbg", + original_language: "eng", + }, + streams, + { audioLanguages: [] }, + ); + expect(result.is_noop).toBe(false); + expect(result.reasons).toContain("Clear comment"); + }); }); describe("analyzeItem — auto_class classification", () => { diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index a5b7426..73c26b2 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { MediaItem, MediaStream, StreamDecision } from "../../types"; -import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams } from "../ffmpeg"; +import { buildCommand, buildPipelineCommand, containerTitle, shellQuote, sortKeptStreams } from "../ffmpeg"; function stream(o: Partial & Pick): MediaStream { return { @@ -54,6 +54,8 @@ const ITEM: MediaItem = { imdb_id: null, tmdb_id: null, tvdb_id: null, + container_title: "Test", + container_comment: null, scan_status: "scanned", scan_error: null, last_scanned_at: null, @@ -114,7 +116,8 @@ describe("buildCommand", () => { 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("-metadata title='Test'"); + expect(cmd).toContain("-metadata comment="); expect(cmd).toContain("'/movies/Test.tmp.mkv'"); expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'"); }); @@ -301,4 +304,106 @@ describe("buildPipelineCommand", () => { expect(command).toContain("-c:a:0 eac3"); expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k }); + + test("writes canonical container title for movies", () => { + const movieItem = { ...ITEM, name: "101 Dalmatians", year: 1961, file_path: "/movies/101.mkv" }; + const streams = [ + 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 = [ + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), + ]; + const cmd = buildCommand(movieItem, streams, decisions); + expect(cmd).toContain("-metadata title='101 Dalmatians (1961)'"); + expect(cmd).toContain("-metadata comment="); + }); + + test("writes canonical container title for episodes", () => { + const epItem: MediaItem = { + ...ITEM, + type: "Episode", + name: "Pilot", + series_name: "Test Show", + year: 2020, + season_number: 1, + episode_number: 2, + file_path: "/tv/Test.Show/S01E02.mkv", + }; + const streams = [ + 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 = [ + decision({ stream_id: 1, action: "keep", target_index: 0 }), + decision({ stream_id: 2, action: "keep", target_index: 0 }), + ]; + const cmd = buildCommand(epItem, streams, decisions); + expect(cmd).toContain("-metadata title='Test Show (2020) - S01E02 - Pilot'"); + }); +}); + +describe("containerTitle", () => { + test("movie with year", () => { + expect(containerTitle({ type: "Movie", name: "101 Dalmatians", year: 1961 } as MediaItem)).toBe( + "101 Dalmatians (1961)", + ); + }); + + test("movie without year", () => { + expect(containerTitle({ type: "Movie", name: "101 Dalmatians", year: null } as MediaItem)).toBe("101 Dalmatians"); + }); + + test("episode with full metadata", () => { + expect( + containerTitle({ + type: "Episode", + name: "Pilot", + series_name: "Test Show", + year: 2020, + season_number: 1, + episode_number: 2, + } as MediaItem), + ).toBe("Test Show (2020) - S01E02 - Pilot"); + }); + + test("episode without year", () => { + expect( + containerTitle({ + type: "Episode", + name: "Pilot", + series_name: "Test Show", + year: null, + season_number: 1, + episode_number: 2, + } as MediaItem), + ).toBe("Test Show - S01E02 - Pilot"); + }); + + test("episode where name equals series drops duplicated tail", () => { + expect( + containerTitle({ + type: "Episode", + name: "Test Show", + series_name: "Test Show", + year: 2020, + season_number: 1, + episode_number: 2, + } as MediaItem), + ).toBe("Test Show (2020) - S01E02"); + }); + + test("pads single-digit season and episode", () => { + expect( + containerTitle({ + type: "Episode", + name: "x", + series_name: "S", + year: null, + season_number: 1, + episode_number: 2, + } as MediaItem), + ).toBe("S - S01E02 - x"); + }); }); diff --git a/server/services/__tests__/probe.test.ts b/server/services/__tests__/probe.test.ts index 7de851f..8f8145c 100644 --- a/server/services/__tests__/probe.test.ts +++ b/server/services/__tests__/probe.test.ts @@ -23,6 +23,31 @@ describe("parseProbeOutput — format metadata", () => { expect(result.fileSize).toBeNull(); expect(result.durationSeconds).toBeNull(); expect(result.container).toBeNull(); + expect(result.containerTitle).toBeNull(); + expect(result.containerComment).toBeNull(); + }); + + test("parses container-level title and comment tags", () => { + const json = JSON.stringify({ + format: { + format_name: "matroska", + tags: { title: "101 Dalmatians (1961)", comment: "rarbg" }, + }, + streams: [], + }); + const result = parseProbeOutput(json); + expect(result.containerTitle).toBe("101 Dalmatians (1961)"); + expect(result.containerComment).toBe("rarbg"); + }); + + test("accepts uppercase TITLE and COMMENT container tags", () => { + const json = JSON.stringify({ + format: { format_name: "matroska", tags: { TITLE: "Movie", COMMENT: "ads" } }, + streams: [], + }); + const result = parseProbeOutput(json); + expect(result.containerTitle).toBe("Movie"); + expect(result.containerComment).toBe("ads"); }); test("takes first part of comma-separated format_name", () => { diff --git a/server/services/__tests__/rescan.test.ts b/server/services/__tests__/rescan.test.ts index 4d37306..d94bcb9 100644 --- a/server/services/__tests__/rescan.test.ts +++ b/server/services/__tests__/rescan.test.ts @@ -28,6 +28,8 @@ const PROBE: ProbeResult = { fileSize: 5_000_000_000, durationSeconds: 7200, container: "matroska", + containerTitle: "Hot.Fuzz.2007.1080p.BluRay.x264-RARBG", + containerComment: "rarbg", streams: [ { streamIndex: 0, @@ -82,6 +84,8 @@ describe("upsertScannedItem", () => { expect(item.file_size).toBe(5_000_000_000); expect(item.duration_seconds).toBe(7200); expect(item.scan_status).toBe("scanned"); + expect(item.container_title).toBe("Hot.Fuzz.2007.1080p.BluRay.x264-RARBG"); + expect(item.container_comment).toBe("rarbg"); const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(result.itemId); expect(streams).toHaveLength(2); diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index 39fe5a5..afafda5 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -1,6 +1,6 @@ import type { MediaItem, MediaStream, PlanResult } from "../types"; import { computeAppleCompat, isAppleCompatible, transcodeTarget } from "./apple-compat"; -import { isExtractableSubtitle, trackTitle } from "./ffmpeg"; +import { containerTitle, isExtractableSubtitle, trackTitle } from "./ffmpeg"; import { normalizeLanguage } from "./language-utils"; const AUTHORITATIVE_ORIG_SOURCES = new Set(["radarr", "sonarr", "manual"]); @@ -35,7 +35,21 @@ function effectiveLanguage(stream: MediaStream, overrides: Map | * language-aware decision (keep/remove, dedup, ordering, is_noop). */ export function analyzeItem( - item: Pick, + item: Pick< + MediaItem, + | "original_language" + | "orig_lang_source" + | "needs_review" + | "container" + | "container_title" + | "container_comment" + | "type" + | "name" + | "year" + | "series_name" + | "season_number" + | "episode_number" + >, streams: MediaStream[], config: AnalyzerConfig, languageOverrides?: Map, @@ -128,6 +142,10 @@ export function analyzeItem( return expected != null && s.title !== expected; }); + const expectedContainerTitle = containerTitle(item); + const containerTitleMismatch = (item.container_title ?? null) !== (expectedContainerTitle ?? null); + const containerCommentDirty = !!item.container_comment && item.container_comment.length > 0; + const reasons: string[] = []; if (anyAudioRemoved) reasons.push("Remove tracks"); if (audioOrderChanged) reasons.push("Reorder"); @@ -137,6 +155,8 @@ export function analyzeItem( if (languageMismatch) reasons.push("Fix language tag"); if (audioTitleMismatch) reasons.push("Fix audio title"); if (videoTitleMismatch) reasons.push("Fix video title"); + if (containerTitleMismatch) reasons.push("Fix container title"); + if (containerCommentDirty) reasons.push("Clear comment"); const is_noop = reasons.length === 0; diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 1f1b10f..a206e91 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -259,6 +259,37 @@ export function audioTitle(stream: MediaStream, customLanguage: string | null = return null; } +type ContainerTitleItem = Pick< + MediaItem, + "type" | "name" | "year" | "series_name" | "season_number" | "episode_number" +>; + +/** + * Canonical container-level title for the media file. + * Movie: "Name (Year)" — or "Name" when year is unknown. + * Episode: "Series (Year) - S01E02 - Episode Title" — each segment drops + * when its source is missing (year absent → "Series - S01E02 - Title", + * episode name absent → "Series (Year) - S01E02"). + * Returns null when there's nothing to build a title from at all. + */ +export function containerTitle(item: ContainerTitleItem): string | null { + if (item.type === "Episode") { + const series = item.series_name ?? item.name ?? null; + if (!series) return null; + const head = item.year ? `${series} (${item.year})` : series; + const hasSeason = item.season_number != null; + const hasEpisode = item.episode_number != null; + const code = hasSeason && hasEpisode + ? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}` + : null; + const epTitle = item.name && item.name !== series ? item.name : null; + const parts = [head, code, epTitle].filter((v): v is string => !!v); + return parts.join(" - "); + } + if (!item.name) return null; + return item.year ? `${item.name} (${item.year})` : item.name; +} + 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 @@ -312,7 +343,10 @@ function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec: * - Writes canonical ISO 639-2/B 3-letter language tags (e.g. "en" → "eng", * "ger" → "deu"). Streams with no language get "und" (ffmpeg convention). */ -function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] { +function buildStreamFlags( + item: ContainerTitleItem, + 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[] = []; @@ -335,7 +369,9 @@ function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): args.push(`-metadata:s:a:${i}`, `language=${lang}`); }); - args.push("-metadata", "title="); + const fileTitle = containerTitle(item); + args.push("-metadata", fileTitle ? `title=${shellQuote(fileTitle)}` : "title="); + args.push("-metadata", "comment="); return args; } @@ -392,7 +428,7 @@ export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], const kept = sortKeptStreams(streams, decisions); const maps = buildMaps(streams, kept); - const streamFlags = buildStreamFlags(kept); + const streamFlags = buildStreamFlags(item, kept); return [ "ffmpeg", @@ -474,7 +510,7 @@ export function buildPipelineCommand( const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"]; // Disposition + metadata flags for audio - const streamFlags = buildStreamFlags(kept); + const streamFlags = buildStreamFlags(item, kept); // Assemble command const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)]; diff --git a/server/services/probe.ts b/server/services/probe.ts index ea470ac..5478af8 100644 --- a/server/services/probe.ts +++ b/server/services/probe.ts @@ -21,6 +21,8 @@ export interface ProbeResult { fileSize: number | null; durationSeconds: number | null; container: string | null; + containerTitle: string | null; + containerComment: string | null; streams: ProbeStream[]; } @@ -61,6 +63,9 @@ export function parseProbeOutput(json: string): ProbeResult { const fileSize = parseIntOrNull(fmt.size ?? null); const durationSeconds = parseFloatOrNull(fmt.duration ?? null); const container = fmt.format_name ? (fmt.format_name as string).split(",")[0] || null : null; + const fmtTags = fmt.tags ?? {}; + const containerTitle = (fmtTags.title ?? fmtTags.TITLE ?? null) as string | null; + const containerComment = (fmtTags.comment ?? fmtTags.COMMENT ?? null) as string | null; const streams: ProbeStream[] = (raw.streams ?? []).map((s: any): ProbeStream => { const tags = s.tags ?? {}; @@ -85,7 +90,7 @@ export function parseProbeOutput(json: string): ProbeResult { }; }); - return { fileSize, durationSeconds, container, streams }; + return { fileSize, durationSeconds, container, containerTitle, containerComment, streams }; } /** Run ffprobe on a local file. */ diff --git a/server/services/rescan.ts b/server/services/rescan.ts index f705d11..5a21cd6 100644 --- a/server/services/rescan.ts +++ b/server/services/rescan.ts @@ -49,9 +49,9 @@ export function upsertScannedItem( file_path, type, name, series_name, series_key, season_number, episode_number, year, file_size, container, duration_seconds, original_language, orig_lang_source, needs_review, - imdb_id, tmdb_id, tvdb_id, + imdb_id, tmdb_id, tvdb_id, container_title, container_comment, scan_status, last_scanned_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now')) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now')) ON CONFLICT(file_path) DO UPDATE SET type = excluded.type, name = excluded.name, series_name = excluded.series_name, series_key = excluded.series_key, @@ -61,6 +61,8 @@ export function upsertScannedItem( original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source, needs_review = excluded.needs_review, imdb_id = excluded.imdb_id, tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id, + container_title = excluded.container_title, + container_comment = excluded.container_comment, scan_status = 'scanned', last_scanned_at = datetime('now') `) .run( @@ -81,6 +83,8 @@ export function upsertScannedItem( parsed.imdbId, parsed.tmdbId, parsed.tvdbId, + probe.containerTitle, + probe.containerComment, ); const row = db.prepare("SELECT id FROM media_items WHERE file_path = ?").get(filePath) as { id: number }; diff --git a/server/services/verify.ts b/server/services/verify.ts index 4d13531..a3a5fe5 100644 --- a/server/services/verify.ts +++ b/server/services/verify.ts @@ -1,6 +1,6 @@ import type { Database } from "bun:sqlite"; -import type { MediaStream, StreamDecision } from "../types"; -import { trackTitle } from "./ffmpeg"; +import type { MediaItem, MediaStream, StreamDecision } from "../types"; +import { containerTitle, trackTitle } from "./ffmpeg"; import { normalizeLanguage } from "./language-utils"; export interface ProbedStream { @@ -11,16 +11,23 @@ export interface ProbedStream { isDefault: number; } -async function ffprobeStreams(filePath: string): Promise { - const proc = Bun.spawn(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", filePath], { - stdout: "pipe", - stderr: "pipe", - }); +export interface ProbedFile { + streams: ProbedStream[]; + containerTitle: string | null; + containerComment: string | null; +} + +async function ffprobeFile(filePath: string): Promise { + const proc = Bun.spawn( + ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath], + { stdout: "pipe", stderr: "pipe" }, + ); const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const exitCode = await proc.exited; if (exitCode !== 0) throw new Error(`ffprobe exited ${exitCode}: ${stderr.trim() || ""}`); const data = JSON.parse(stdout) as { + format?: { tags?: { title?: string; TITLE?: string; comment?: string; COMMENT?: string } }; streams?: Array<{ codec_type?: string; codec_name?: string; @@ -28,13 +35,18 @@ async function ffprobeStreams(filePath: string): Promise { disposition?: { default?: number }; }>; }; - return (data.streams ?? []).map((s) => ({ - type: codecTypeToType(s.codec_type), - codec: s.codec_name ?? null, - language: s.tags?.language ?? s.tags?.LANGUAGE ?? null, - title: s.tags?.title ?? s.tags?.TITLE ?? null, - isDefault: s.disposition?.default ?? 0, - })); + const fmtTags = data.format?.tags ?? {}; + return { + streams: (data.streams ?? []).map((s) => ({ + type: codecTypeToType(s.codec_type), + codec: s.codec_name ?? null, + language: s.tags?.language ?? s.tags?.LANGUAGE ?? null, + title: s.tags?.title ?? s.tags?.TITLE ?? null, + isDefault: s.disposition?.default ?? 0, + })), + containerTitle: fmtTags.title ?? fmtTags.TITLE ?? null, + containerComment: fmtTags.comment ?? fmtTags.COMMENT ?? null, + }; } function codecTypeToType(t: string | undefined): ProbedStream["type"] { @@ -150,6 +162,9 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath: | undefined; if (!plan) return { matches: false, reason: "no review plan found" }; + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; + if (!item) return { matches: false, reason: "no media item found" }; + const expected = db .prepare(` SELECT ms.*, sd.id as decision_id, sd.plan_id, sd.stream_id, sd.action, @@ -161,22 +176,33 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath: `) .all(plan.id) as ExpectedKeptStream[]; - let probed: ProbedStream[]; + let probed: ProbedFile; try { - probed = await ffprobeStreams(filePath); + probed = await ffprobeFile(filePath); } catch (err) { return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` }; } - const probedSubs = probed.filter((s) => s.type === "Subtitle"); + const probedSubs = probed.streams.filter((s) => s.type === "Subtitle"); if (probedSubs.length > 0) { return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` }; } - const mismatch = verifyStreamMetadata(expected, probed); + const mismatch = verifyStreamMetadata(expected, probed.streams); if (mismatch) return mismatch; + const expectedContainerTitle = containerTitle(item); + if ((probed.containerTitle ?? null) !== (expectedContainerTitle ?? null)) { + return { + matches: false, + reason: `container title ${probed.containerTitle || ""} ≠ expected ${expectedContainerTitle ?? ""}`, + }; + } + if (probed.containerComment && probed.containerComment.length > 0) { + return { matches: false, reason: `container comment not empty: ${probed.containerComment}` }; + } + if (plan.subs_extracted === 0) { const pendingSubs = db .prepare(` diff --git a/server/types.ts b/server/types.ts index 578cb06..3a6ed9c 100644 --- a/server/types.ts +++ b/server/types.ts @@ -19,6 +19,8 @@ export interface MediaItem { imdb_id: string | null; tmdb_id: string | null; tvdb_id: string | null; + container_title: string | null; + container_comment: string | null; scan_status: "pending" | "scanned" | "error"; scan_error: string | null; last_scanned_at: string | null; diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 4f21f5b..8b8d34e 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -19,6 +19,8 @@ export interface MediaItem { imdb_id: string | null; tmdb_id: string | null; tvdb_id: string | null; + container_title: string | null; + container_comment: string | null; scan_status: string; last_scanned_at: string | null; }