748145a372
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.
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import type { MediaItem, MediaStream, StreamDecision } from "../../types";
|
|
import { buildCommand, buildPipelineCommand, containerTitle, shellQuote, sortKeptStreams } from "../ffmpeg";
|
|
|
|
function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">): MediaStream {
|
|
return {
|
|
item_id: 1,
|
|
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 decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id" | "action">): StreamDecision {
|
|
return {
|
|
id: 0,
|
|
plan_id: 1,
|
|
target_index: null,
|
|
custom_title: null,
|
|
custom_language: null,
|
|
transcode_codec: null,
|
|
...o,
|
|
};
|
|
}
|
|
|
|
const ITEM: MediaItem = {
|
|
id: 1,
|
|
type: "Movie",
|
|
name: "Test",
|
|
series_name: null,
|
|
series_key: null,
|
|
season_number: null,
|
|
episode_number: null,
|
|
year: null,
|
|
file_path: "/movies/Test.mkv",
|
|
file_size: null,
|
|
container: "mkv",
|
|
duration_seconds: null,
|
|
original_language: "eng",
|
|
orig_lang_source: "probe",
|
|
needs_review: 0,
|
|
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,
|
|
last_executed_at: null,
|
|
created_at: "",
|
|
};
|
|
|
|
describe("shellQuote", () => {
|
|
test("wraps plain strings in single quotes", () => {
|
|
expect(shellQuote("hello")).toBe("'hello'");
|
|
});
|
|
|
|
test("escapes single quotes safely", () => {
|
|
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
|
});
|
|
|
|
test("handles paths with spaces", () => {
|
|
expect(shellQuote("/movies/My Movie.mkv")).toBe("'/movies/My Movie.mkv'");
|
|
});
|
|
});
|
|
|
|
describe("sortKeptStreams", () => {
|
|
test("orders by type priority (Video, Audio, Subtitle, Data), then target_index", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Audio", stream_index: 1 }),
|
|
stream({ id: 2, type: "Video", stream_index: 0 }),
|
|
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 1 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
|
];
|
|
const sorted = sortKeptStreams(streams, decisions);
|
|
expect(sorted.map((k) => k.stream.id)).toEqual([2, 3, 1]);
|
|
});
|
|
|
|
test("drops streams with action remove", () => {
|
|
const streams = [stream({ id: 1, type: "Audio", stream_index: 0 })];
|
|
const decisions = [decision({ stream_id: 1, action: "remove" })];
|
|
expect(sortKeptStreams(streams, decisions)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("buildCommand", () => {
|
|
test("produces ffmpeg remux with tmp-rename pattern", () => {
|
|
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(ITEM, streams, decisions);
|
|
expect(cmd).toContain("ffmpeg");
|
|
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='Test'");
|
|
expect(cmd).toContain("-metadata comment=");
|
|
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
|
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'");
|
|
});
|
|
|
|
test("uses type-relative specifiers (0:a:N) not absolute stream_index", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1 }),
|
|
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
|
];
|
|
// Keep only the second audio; still mapped as 0:a:1
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "remove" }),
|
|
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
expect(cmd).toContain("-map 0:a:1");
|
|
expect(cmd).not.toContain("-map 0:a:2");
|
|
});
|
|
|
|
test("extracts every subtitle to a sidecar even when audio decisions are the main reason to process", () => {
|
|
// Regression: buildCommand used to strip subtitles from the container
|
|
// without extracting them first — a 37-subtitle file would have lost
|
|
// all 37. It now delegates to buildPipelineCommand, so every subtitle
|
|
// gets a -map 0:s:N extraction output.
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
|
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "fra" }),
|
|
stream({ id: 4, type: "Subtitle", stream_index: 3, codec: "subrip", language: "eng" }),
|
|
stream({ id: 5, type: "Subtitle", stream_index: 4, codec: "subrip", language: "deu" }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "remove" }),
|
|
decision({ stream_id: 4, action: "remove" }),
|
|
decision({ stream_id: 5, action: "remove" }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
expect(cmd).toContain("-map 0:s:0");
|
|
expect(cmd).toContain("-map 0:s:1");
|
|
expect(cmd).toContain("'/movies/Test.en.srt'");
|
|
expect(cmd).toContain("'/movies/Test.de.srt'");
|
|
});
|
|
|
|
test("writes canonical iso3 language metadata on every kept audio stream", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, language: "en" }), // 2-letter → eng
|
|
stream({ id: 3, type: "Audio", stream_index: 2, language: "ger" }), // alias → deu
|
|
stream({ id: 4, type: "Audio", stream_index: 3, language: null }), // unknown → und
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "keep", target_index: 1 }),
|
|
decision({ stream_id: 4, action: "keep", target_index: 2 }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
expect(cmd).toContain("-metadata:s:a:0 language=eng");
|
|
expect(cmd).toContain("-metadata:s:a:1 language=deu");
|
|
expect(cmd).toContain("-metadata:s:a:2 language=und");
|
|
});
|
|
|
|
test("writes canonical 'ENG - CODEC CHANNELS' title on every kept audio stream", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({
|
|
id: 2,
|
|
type: "Audio",
|
|
stream_index: 1,
|
|
codec: "ac3",
|
|
channels: 6,
|
|
language: "eng",
|
|
title: "Audio Description",
|
|
}),
|
|
stream({ id: 3, type: "Audio", stream_index: 2, codec: "dts", channels: 1, language: "deu" }),
|
|
stream({ id: 4, type: "Audio", stream_index: 3, codec: "aac", channels: 2, language: null }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "keep", target_index: 1 }),
|
|
decision({ stream_id: 4, action: "keep", target_index: 2 }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
// Original "Audio Description" title is replaced with the harmonized form.
|
|
expect(cmd).toContain("-metadata:s:a:0 title='ENG - AC3 5.1'");
|
|
// Mono renders as 1.0 (not the legacy "mono" string).
|
|
expect(cmd).toContain("-metadata:s:a:1 title='DEU - DTS 1.0'");
|
|
// Stereo renders as 2.0.
|
|
expect(cmd).toContain("-metadata:s:a:2 title='AAC 2.0'");
|
|
});
|
|
|
|
test("custom_title still overrides the auto-generated audio title", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "ac3", channels: 6, language: "eng" }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0, custom_title: "Director's Cut" }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
expect(cmd).toContain("-metadata:s:a:0 title='Director'\\''s Cut'");
|
|
expect(cmd).not.toContain("ENG - AC3");
|
|
});
|
|
|
|
test("sets first kept audio as default, clears others", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, language: "eng" }),
|
|
stream({ id: 3, type: "Audio", stream_index: 2, language: "deu" }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "keep", target_index: 1 }),
|
|
];
|
|
const cmd = buildCommand(ITEM, streams, decisions);
|
|
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", () => {
|
|
test("emits subtitle extraction outputs and final remux in one pass", () => {
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
|
stream({ id: 3, type: "Subtitle", stream_index: 2, codec: "subrip", language: "eng" }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 3, action: "remove" }),
|
|
];
|
|
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
|
|
expect(command).toContain("-map 0:s:0");
|
|
expect(command).toContain("-c:s copy");
|
|
expect(command).toContain("'/movies/Test.en.srt'");
|
|
expect(command).toContain("-map 0:v:0");
|
|
expect(command).toContain("-map 0:a:0");
|
|
expect(extractedFiles).toHaveLength(1);
|
|
expect(extractedFiles[0].path).toBe("/movies/Test.en.srt");
|
|
});
|
|
|
|
test("transcodes incompatible audio with per-track codec flag", () => {
|
|
const dtsItem = { ...ITEM, container: "mp4", file_path: "/movies/x.mp4" };
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }),
|
|
];
|
|
const decisions = [
|
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
|
decision({ stream_id: 2, action: "keep", target_index: 0, transcode_codec: "eac3" }),
|
|
];
|
|
const { command } = buildPipelineCommand(dtsItem, streams, decisions);
|
|
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");
|
|
});
|
|
});
|