pipeline card: checkboxes over actual audio streams, not a language dropdown
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m3s

The dropdown showed every language known to LANG_NAMES — not useful
because you can only keep streams that actually exist on the file. The
right tool is checkboxes, one per track, pre-checked per analyzer
decisions.

- /api/review/pipeline now returns audio_streams[] per review item
  with id, language, codec, channels, title, is_default, and the
  current keep/remove action
- PipelineCard renders one line per audio track: checkbox (bound to
  PATCH /:id/stream/:streamId), language, codec·channels, default
  badge, title, and '(Original Language)' when the stream's normalized
  language matches the item's OG (which itself comes from
  radarr/sonarr/jellyfin via the scan flow)
- ReviewColumn + SeriesCard swap onLanguageChange → onToggleStream
- new shared normalizeLanguageClient mirrors the server's normalize so
  en/eng compare equal on the client
This commit is contained in:
2026-04-14 10:13:37 +02:00
parent 6698af020d
commit aca627930f
7 changed files with 181 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "netfelix-audio-fix", "name": "netfelix-audio-fix",
"version": "2026.04.14.10", "version": "2026.04.14.11",
"scripts": { "scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite", "dev:client": "vite",

View File

@@ -252,6 +252,16 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
// ─── Pipeline: summary ─────────────────────────────────────────────────────── // ─── Pipeline: summary ───────────────────────────────────────────────────────
interface PipelineAudioStream {
id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove";
}
app.get("/pipeline", (c) => { app.get("/pipeline", (c) => {
const db = getDb(); const db = getDb();
const jellyfinUrl = getConfig("jellyfin_url") ?? ""; const jellyfinUrl = getConfig("jellyfin_url") ?? "";
@@ -348,6 +358,50 @@ app.get("/pipeline", (c) => {
item.transcode_reasons = reasonsByPlan.get(item.id) ?? []; item.transcode_reasons = reasonsByPlan.get(item.id) ?? [];
} }
// Batch-load audio streams + their current decisions so each card can
// render pre-checked checkboxes without an extra fetch. Only audio
// streams (video/subtitle aren't user-toggleable from the card).
const itemIds = (review as { item_id: number }[]).map((r) => r.item_id);
const streamsByItem = new Map<number, PipelineAudioStream[]>();
if (itemIds.length > 0) {
const placeholders = itemIds.map(() => "?").join(",");
const streamRows = db
.prepare(`
SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title,
ms.is_default, sd.action
FROM media_streams ms
JOIN review_plans rp ON rp.item_id = ms.item_id
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id
WHERE ms.item_id IN (${placeholders}) AND ms.type = 'Audio'
ORDER BY ms.item_id, ms.stream_index
`)
.all(...itemIds) as {
id: number;
item_id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove" | null;
}[];
for (const r of streamRows) {
if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []);
streamsByItem.get(r.item_id)!.push({
id: r.id,
language: r.language,
codec: r.codec,
channels: r.channels,
title: r.title,
is_default: r.is_default,
action: r.action ?? "keep",
});
}
}
for (const item of review as { item_id: number; audio_streams?: PipelineAudioStream[] }[]) {
item.audio_streams = streamsByItem.get(item.item_id) ?? [];
}
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl }); return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
}); });

View File

@@ -1,27 +1,41 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge"; import { Badge } from "~/shared/components/ui/badge";
import { LANG_NAMES, langName } from "~/shared/lib/lang"; import { langName, normalizeLanguageClient } from "~/shared/lib/lang";
import type { PipelineReviewItem } from "~/shared/lib/types"; import type { PipelineAudioStream, PipelineReviewItem } from "~/shared/lib/types";
// Accepts pipeline rows (plan+item) and also raw media_item rows (card is // Accepts pipeline rows (plan+item) and also raw media_item rows (card is
// reused in a couple of list contexts where no plan is attached yet). // reused in a couple of list contexts where no plan is attached yet).
type PipelineCardItem = type PipelineCardItem =
| PipelineReviewItem | PipelineReviewItem
| (Omit<PipelineReviewItem, "item_id" | "transcode_reasons"> & { | (Omit<PipelineReviewItem, "item_id" | "transcode_reasons" | "audio_streams"> & {
id: number; id: number;
item_id?: number; item_id?: number;
transcode_reasons?: string[]; transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}); });
interface PipelineCardProps { interface PipelineCardProps {
item: PipelineCardItem; item: PipelineCardItem;
jellyfinUrl: string; jellyfinUrl: string;
onLanguageChange?: (lang: string) => void; onToggleStream?: (streamId: number, nextAction: "keep" | "remove") => void;
onApprove?: () => void; onApprove?: () => void;
onSkip?: () => void; onSkip?: () => void;
} }
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, onSkip }: PipelineCardProps) { 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`);
}
return parts.join(" · ");
}
export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onSkip }: PipelineCardProps) {
const title = const title =
item.type === "Episode" item.type === "Episode"
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}${item.name}` ? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}${item.name}`
@@ -54,23 +68,6 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, o
<p className="text-sm font-medium truncate">{title}</p> <p className="text-sm font-medium truncate">{title}</p>
)} )}
<div className="flex items-center gap-1.5 mt-1 flex-wrap"> <div className="flex items-center gap-1.5 mt-1 flex-wrap">
{onLanguageChange ? (
<select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
value={item.original_language ?? ""}
onChange={(e) => onLanguageChange(e.target.value)}
>
<option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
) : (
<Badge variant="default">{langName(item.original_language)}</Badge>
)}
{item.transcode_reasons && item.transcode_reasons.length > 0 {item.transcode_reasons && item.transcode_reasons.length > 0
? item.transcode_reasons.map((r) => ( ? item.transcode_reasons.map((r) => (
<Badge key={r} variant="manual"> <Badge key={r} variant="manual">
@@ -79,6 +76,41 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, o
)) ))
: item.job_type === "copy" && <Badge variant="noop">copy</Badge>} : item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
</div> </div>
{/* Audio streams: checkboxes over the actual tracks on this file,
pre-checked per analyzer decisions. The track whose language
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">
{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">
<input
type="checkbox"
className="h-3.5 w-3.5"
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 && !isOriginal && (
<span className="text-gray-400 truncate" title={s.title}>
{s.title}
</span>
)}
{isOriginal && <span className="text-green-700 text-[11px]">(Original Language)</span>}
</li>
);
})}
</ul>
)}
</div> </div>
</div> </div>

View File

@@ -85,8 +85,8 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
key={entry.item.id} key={entry.item.id}
item={entry.item} item={entry.item}
jellyfinUrl={jellyfinUrl} jellyfinUrl={jellyfinUrl}
onLanguageChange={async (lang) => { onToggleStream={async (streamId, action) => {
await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang }); await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action });
onMutate(); onMutate();
}} }}
onApprove={() => approveItem(entry.item.item_id)} onApprove={() => approveItem(entry.item.item_id)}

View File

@@ -103,8 +103,8 @@ export function SeriesCard({
key={ep.id} key={ep.id}
item={ep} item={ep}
jellyfinUrl={jellyfinUrl} jellyfinUrl={jellyfinUrl}
onLanguageChange={async (lang) => { onToggleStream={async (streamId, action) => {
await api.patch(`/api/review/${ep.item_id}/language`, { language: lang }); await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action });
onMutate(); onMutate();
}} }}
onApprove={async () => { onApprove={async () => {

View File

@@ -51,3 +51,60 @@ export function langName(code: string | null | undefined): string {
if (!code) return "—"; if (!code) return "—";
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase(); return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
} }
// Common ISO 639-1 (2-letter) → ISO 639-2/3 (3-letter) aliases we actually
// see from Jellyfin / MediaInfo. Enough to compare against our canonical
// iso3 original_language without pulling in a full lib.
const ISO2_TO_ISO3: Record<string, string> = {
en: "eng",
de: "deu",
ger: "deu",
es: "spa",
fr: "fra",
fre: "fra",
it: "ita",
pt: "por",
ja: "jpn",
ko: "kor",
zh: "zho",
chi: "zho",
ar: "ara",
ru: "rus",
nl: "nld",
dut: "nld",
sv: "swe",
no: "nor",
da: "dan",
fi: "fin",
pl: "pol",
tr: "tur",
th: "tha",
hi: "hin",
hu: "hun",
cs: "ces",
cze: "ces",
ro: "ron",
rum: "ron",
el: "ell",
gre: "ell",
he: "heb",
fa: "fas",
per: "fas",
uk: "ukr",
id: "ind",
ms: "msa",
may: "msa",
vi: "vie",
ca: "cat",
};
/**
* Client-side language normalization: returns a lowercase iso3 tag so two
* streams tagged `en` and `eng` compare equal. Mirrors the server's
* `normalizeLanguage` without the server-only deps.
*/
export function normalizeLanguageClient(code: string | null | undefined): string | null {
if (!code) return null;
const lower = code.toLowerCase().trim();
return ISO2_TO_ISO3[lower] ?? lower;
}

View File

@@ -116,6 +116,17 @@ export interface PipelineReviewItem {
file_path: string; file_path: string;
// computed // computed
transcode_reasons: string[]; transcode_reasons: string[];
audio_streams: PipelineAudioStream[];
}
export interface PipelineAudioStream {
id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove";
} }
/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. */ /** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. */