detect dirty container title and comment, rewrite to canonical form
Build and Push Docker Image / build (push) Successful in 3m57s
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:
+13
-8
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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(`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user