Files
Felix Förtsch 1de5b8a89e
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
address audit findings: subtitle rescan decisions, scan limit, parseId, setup gate
worked through AUDIT.md. triage:
- finding 2 (subtitle rescan wipes decisions): confirmed. /:id/rescan now
  snapshots custom_titles and calls reanalyze() after the stream delete/
  insert, mirroring the review rescan flow. exported reanalyze + titleKey
  from review.ts so both routes share the logic.
- finding 3 (scan limit accepts NaN/negatives): confirmed. extracted
  parseScanLimit into a pure helper, added unit tests covering NaN,
  negatives, floats, infinity, numeric strings. invalid input 400s and
  releases the scan_running lock.
- finding 4 (parseId lenient): confirmed. tightened the regex to /^\d+$/
  so "42abc", "abc42", "+42", "42.0" all return null. rewrote the test
  that codified the old lossy behaviour.
- finding 5 (setup_complete set before jellyfin test passes): confirmed.
  the /jellyfin endpoint still persists url+key unconditionally, but now
  only flips setup_complete=1 on a successful connection test.
- finding 6 (swallowed errors): partial. the mqtt restart and version-
  fetch swallows are intentional best-effort with downstream surfaces
  (getMqttStatus, UI fallback). only the scan.ts db-update swallow was
  a real visibility gap — logs via logError now.
- finding 1 (auth): left as-is. redacting secrets on GET without auth
  on POST is security theater; real fix is an auth layer, which is a
  design decision not a bugfix. audit removed from the tree.
- lint fail on ffmpeg.test.ts: formatted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:41:36 +02:00

307 lines
12 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { MediaItem, MediaStream, StreamDecision } from "../../types";
import { buildCommand, buildPipelineCommand, predictExtractedFiles, 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,
language_display: 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<StreamDecision> & Pick<StreamDecision, "stream_id" | "action">): StreamDecision {
return {
id: 0,
plan_id: 1,
target_index: null,
custom_title: null,
transcode_codec: null,
...o,
};
}
const ITEM: MediaItem = {
id: 1,
jellyfin_id: "x",
type: "Movie",
name: "Test",
original_title: null,
series_name: null,
series_jellyfin_id: null,
season_number: null,
episode_number: null,
year: null,
file_path: "/movies/Test.mkv",
file_size: null,
container: "mkv",
runtime_ticks: null,
date_last_refreshed: null,
original_language: "eng",
orig_lang_source: "jellyfin",
needs_review: 0,
imdb_id: null,
tmdb_id: null,
tvdb_id: null,
jellyfin_raw: null,
external_raw: 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
});
});
describe("predictExtractedFiles", () => {
test("predicts sidecar paths matching extraction output", () => {
const streams = [
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }),
];
const files = predictExtractedFiles(ITEM, streams);
expect(files).toHaveLength(2);
expect(files[0].file_path).toBe("/movies/Test.en.srt");
expect(files[1].file_path).toBe("/movies/Test.de.forced.srt");
expect(files[1].is_forced).toBe(true);
});
test("deduplicates paths with a numeric suffix", () => {
const streams = [
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
];
const files = predictExtractedFiles(ITEM, streams);
expect(files[0].file_path).toBe("/movies/Test.en.srt");
expect(files[1].file_path).toBe("/movies/Test.en.2.srt");
});
});