audio titles: rewrite to canonical 'ENG - CODEC · CHANNELS', two-line pipeline card
All checks were successful
Build and Push Docker Image / build (push) Successful in 59s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 16:02:32 +02:00
parent 027ea498c3
commit 9cdc054c4b
4 changed files with 98 additions and 26 deletions

View File

@@ -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",

View File

@@ -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 }),

View File

@@ -207,6 +207,20 @@ const LANG_NAMES: Record<string, string> = {
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<string, string> = { Video: "v", Audio: "a", Subtitle: "s" };

View File

@@ -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 && (
<ul className="mt-2 space-y-0.5">
<ul className="mt-2 space-y-1.5">
{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 (
<li key={s.id} className="flex items-center gap-1.5 text-xs">
<li key={s.id} className="flex items-start gap-1.5 text-xs">
<input
type="checkbox"
className="h-3.5 w-3.5"
className="h-3.5 w-3.5 mt-0.5 shrink-0"
checked={s.action === "keep"}
onChange={(e) => onToggleStream?.(s.id, e.target.checked ? "keep" : "remove")}
disabled={!onToggleStream}
/>
<span className="font-medium">{langName(s.language) || "unknown"}</span>
{description && <span className="text-gray-500">{description}</span>}
{s.is_default === 1 && <span className="text-[10px] text-gray-400 uppercase">default</span>}
{s.title && (
<span className="text-gray-400 truncate" title={s.title}>
{s.title}
</span>
)}
{isOriginal && <span className="text-green-700 text-[11px]">(Original Language)</span>}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium">{langName(s.language) || "unknown"}</span>
{isOriginal && <span className="text-green-700 text-[11px]">(Original Language)</span>}
</div>
{(description || s.title || s.is_default === 1) && (
<div className="flex items-baseline gap-1.5 flex-wrap text-gray-500">
{description && <span>{description}</span>}
{s.is_default === 1 && <span className="text-[10px] text-gray-400 uppercase">default</span>}
{s.title && (
<span className="text-gray-400 break-words" title={s.title}>
{s.title}
</span>
)}
</div>
)}
</div>
</li>
);
})}