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 & Pick): 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 & Pick): 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"); }); });