detect dirty container title and comment, rewrite to canonical form
Build and Push Docker Image / build (push) Successful in 3m57s

Track format.tags.title and format.tags.comment on media_items via a new
containerTitle() helper producing "Name (Year)" for movies and
"Series (Year) - S01E02 - Title" for episodes. Analyzer and
recomputePlanAfterToggle now flag non-canonical container title and
non-empty comment as non-noop ("Fix container title", "Clear comment"),
and verifyDesiredState checks them post-ffmpeg. buildStreamFlags writes
the canonical title and clears comment on every run.

Existing libraries need a rescan to populate the new columns.
This commit is contained in:
2026-04-24 21:45:39 +02:00
parent e6684dd748
commit 748145a372
15 changed files with 350 additions and 38 deletions
+13 -8
View File
@@ -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<typeof getDb>, 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<typeof getDb>, 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
+2
View File
@@ -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).
+2
View File
@@ -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,
@@ -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", () => {
+107 -2
View File
@@ -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<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">): 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");
});
});
+25
View File
@@ -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", () => {
+4
View File
@@ -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);
+22 -2
View File
@@ -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<string>(["radarr", "sonarr", "manual"]);
@@ -35,7 +35,21 @@ function effectiveLanguage(stream: MediaStream, overrides: Map<number, string> |
* language-aware decision (keep/remove, dedup, ordering, is_noop).
*/
export function analyzeItem(
item: Pick<MediaItem, "original_language" | "orig_lang_source" | "needs_review" | "container">,
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<number, string>,
@@ -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;
+40 -4
View File
@@ -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)];
+6 -1
View File
@@ -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. */
+6 -2
View File
@@ -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 };
+44 -18
View File
@@ -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<ProbedStream[]> {
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<ProbedFile> {
const proc = Bun.spawn(
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const exitCode = await proc.exited;
if (exitCode !== 0) throw new Error(`ffprobe exited ${exitCode}: ${stderr.trim() || "<no stderr>"}`);
const data = JSON.parse(stdout) as {
format?: { tags?: { title?: string; TITLE?: string; comment?: string; COMMENT?: string } };
streams?: Array<{
codec_type?: string;
codec_name?: string;
@@ -28,13 +35,18 @@ async function ffprobeStreams(filePath: string): Promise<ProbedStream[]> {
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 || "<none>"} ≠ expected ${expectedContainerTitle ?? "<none>"}`,
};
}
if (probed.containerComment && probed.containerComment.length > 0) {
return { matches: false, reason: `container comment not empty: ${probed.containerComment}` };
}
if (plan.subs_extracted === 0) {
const pendingSubs = db
.prepare(`
+2
View File
@@ -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;