import { describe, expect, test } from "bun:test"; import type { MediaItem, MediaStream, StreamDecision } from "../../types"; import { buildCommand, buildPipelineCommand, 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, ...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, 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" }), 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("'/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"); }); }); 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 }); });