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
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:
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user