From 9cdc054c4b39196d61a38d45aaa35ec9bfe6b0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 14 Apr 2026 16:02:32 +0200 Subject: [PATCH] =?UTF-8?q?audio=20titles:=20rewrite=20to=20canonical=20'E?= =?UTF-8?q?NG=20-=20CODEC=20=C2=B7=20CHANNELS',=20two-line=20pipeline=20ca?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audio tracks now get a harmonized title on output (overriding any file title like 'Audio Description' — review has already filtered out tracks we don't want to keep). mono/stereo render numerically (1.0/2.0), matching the .1-suffixed surround layouts. pipeline card rows become two-line so long titles wrap instead of being clipped by the column. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- server/services/__tests__/ffmpeg.test.ts | 36 +++++++++++++++++ server/services/ffmpeg.ts | 35 +++++++++++++--- src/features/pipeline/PipelineCard.tsx | 51 +++++++++++++++--------- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 32544de..6d8564b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.14.14", + "version": "2026.04.14.15", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index cc104d9..757e29f 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -182,6 +182,42 @@ describe("buildCommand", () => { 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 }), diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 1dbb328..85fc814 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -207,6 +207,20 @@ const LANG_NAMES: Record = { est: "Estonian", }; +/** + * Channel count → "N.M" layout string (5.1, 7.1, 2.0, 1.0). + * Falls back to "Nch" for anything outside the common consumer layouts. + */ +function formatChannels(n: number | null): string | null { + if (n == null) return null; + if (n === 1) return "1.0"; + if (n === 2) return "2.0"; + if (n === 6) return "5.1"; + if (n === 7) return "6.1"; + if (n === 8) return "7.1"; + return `${n}ch`; +} + function trackTitle(stream: MediaStream): string | null { if (stream.type === "Subtitle") { // Subtitles always get a clean language-based title so Jellyfin displays @@ -220,12 +234,21 @@ function trackTitle(stream: MediaStream): string | null { if (stream.is_hearing_impaired) return `${base} (CC)`; return base; } - // For audio and other stream types: preserve any existing title - // (e.g. "Director's Commentary") and fall back to language name. - if (stream.title) return stream.title; - if (!stream.language) return null; - const lang = normalizeLanguage(stream.language); - return LANG_NAMES[lang] ?? lang.toUpperCase(); + // Audio: harmonize to "ENG - AC3 · 5.1". Overrides whatever the file had + // (e.g. "Audio Description", "Director's Commentary") — the user uses + // the review UI to drop unwanted tracks before we get here, so by this + // point every kept audio track is a primary track that deserves a clean + // canonical label. If a user wants a different title, custom_title on + // the decision still wins (see buildStreamFlags). + const lang = stream.language ? normalizeLanguage(stream.language) : null; + const langPart = lang ? lang.toUpperCase() : null; + const codecPart = stream.codec ? stream.codec.toUpperCase() : null; + const channelsPart = formatChannels(stream.channels); + const tail = [codecPart, channelsPart].filter((v): v is string => !!v).join(" · "); + if (langPart && tail) return `${langPart} - ${tail}`; + if (langPart) return langPart; + if (tail) return tail; + return null; } const TYPE_SPEC: Record = { Video: "v", Audio: "a", Subtitle: "s" }; diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index e71f224..b6d40bc 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -34,16 +34,21 @@ interface PipelineCardProps { onUnapprove?: () => void; } +function formatChannels(n: number | null | undefined): string | null { + if (n == null) return null; + if (n === 1) return "1.0"; + if (n === 2) return "2.0"; + if (n === 6) return "5.1"; + if (n === 7) return "6.1"; + if (n === 8) return "7.1"; + return `${n}ch`; +} + function describeStream(s: PipelineAudioStream): string { const parts: string[] = []; if (s.codec) parts.push(s.codec.toUpperCase()); - if (s.channels != null) { - if (s.channels === 6) parts.push("5.1"); - else if (s.channels === 8) parts.push("7.1"); - else if (s.channels === 2) parts.push("stereo"); - else if (s.channels === 1) parts.push("mono"); - else parts.push(`${s.channels}ch`); - } + const ch = formatChannels(s.channels); + if (ch) parts.push(ch); return parts.join(" · "); } @@ -94,30 +99,38 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS matches the item's OG (set from radarr/sonarr/jellyfin) is marked "(Original Language)". */} {item.audio_streams && item.audio_streams.length > 0 && ( -
    +
      {item.audio_streams.map((s) => { const ogLang = item.original_language ? normalizeLanguageClient(item.original_language) : null; const sLang = s.language ? normalizeLanguageClient(s.language) : null; const isOriginal = !!(ogLang && sLang && ogLang === sLang); const description = describeStream(s); return ( -
    • +
    • onToggleStream?.(s.id, e.target.checked ? "keep" : "remove")} disabled={!onToggleStream} /> - {langName(s.language) || "unknown"} - {description && {description}} - {s.is_default === 1 && default} - {s.title && ( - - “{s.title}” - - )} - {isOriginal && (Original Language)} +
      +
      + {langName(s.language) || "unknown"} + {isOriginal && (Original Language)} +
      + {(description || s.title || s.is_default === 1) && ( +
      + {description && {description}} + {s.is_default === 1 && default} + {s.title && ( + + — “{s.title}” + + )} +
      + )} +
    • ); })}