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

@@ -252,6 +252,16 @@ function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number):
// ─── 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) => {
const db = getDb();
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
@@ -348,6 +358,50 @@ app.get("/pipeline", (c) => {
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 });
});