From aca627930f5fbeca20bce4b1ab8e8bd4d958acb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 14 Apr 2026 10:13:37 +0200 Subject: [PATCH] pipeline card: checkboxes over actual audio streams, not a language dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- server/api/review.ts | 54 ++++++++++++++++++ src/features/pipeline/PipelineCard.tsx | 76 ++++++++++++++++++-------- src/features/pipeline/ReviewColumn.tsx | 4 +- src/features/pipeline/SeriesCard.tsx | 4 +- src/shared/lib/lang.ts | 57 +++++++++++++++++++ src/shared/lib/types.ts | 11 ++++ 7 files changed, 181 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 15e93c1..d36c5f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.14.10", + "version": "2026.04.14.11", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/review.ts b/server/api/review.ts index 451f136..72f2520 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -252,6 +252,16 @@ function recomputePlanAfterToggle(db: ReturnType, 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(); + 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 }); }); diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 391621f..9e33017 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -1,27 +1,41 @@ import { Link } from "@tanstack/react-router"; import { Badge } from "~/shared/components/ui/badge"; -import { LANG_NAMES, langName } from "~/shared/lib/lang"; -import type { PipelineReviewItem } from "~/shared/lib/types"; +import { langName, normalizeLanguageClient } from "~/shared/lib/lang"; +import type { PipelineAudioStream, PipelineReviewItem } from "~/shared/lib/types"; // 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). type PipelineCardItem = | PipelineReviewItem - | (Omit & { + | (Omit & { id: number; item_id?: number; transcode_reasons?: string[]; + audio_streams?: PipelineAudioStream[]; }); interface PipelineCardProps { item: PipelineCardItem; jellyfinUrl: string; - onLanguageChange?: (lang: string) => void; + onToggleStream?: (streamId: number, nextAction: "keep" | "remove") => void; onApprove?: () => 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 = item.type === "Episode" ? `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

{title}

)}
- {onLanguageChange ? ( - - ) : ( - {langName(item.original_language)} - )} - {item.transcode_reasons && item.transcode_reasons.length > 0 ? item.transcode_reasons.map((r) => ( @@ -79,6 +76,41 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, o )) : item.job_type === "copy" && copy}
+ + {/* 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 && ( +
    + {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 && !isOriginal && ( + + “{s.title}” + + )} + {isOriginal && (Original Language)} +
  • + ); + })} +
+ )} diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index d7f9d19..5479258 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -85,8 +85,8 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu key={entry.item.id} item={entry.item} jellyfinUrl={jellyfinUrl} - onLanguageChange={async (lang) => { - await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang }); + onToggleStream={async (streamId, action) => { + await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action }); onMutate(); }} onApprove={() => approveItem(entry.item.item_id)} diff --git a/src/features/pipeline/SeriesCard.tsx b/src/features/pipeline/SeriesCard.tsx index 344af34..8cb3e6e 100644 --- a/src/features/pipeline/SeriesCard.tsx +++ b/src/features/pipeline/SeriesCard.tsx @@ -103,8 +103,8 @@ export function SeriesCard({ key={ep.id} item={ep} jellyfinUrl={jellyfinUrl} - onLanguageChange={async (lang) => { - await api.patch(`/api/review/${ep.item_id}/language`, { language: lang }); + onToggleStream={async (streamId, action) => { + await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action }); onMutate(); }} onApprove={async () => { diff --git a/src/shared/lib/lang.ts b/src/shared/lib/lang.ts index 36ef6a3..bf50c16 100644 --- a/src/shared/lib/lang.ts +++ b/src/shared/lib/lang.ts @@ -51,3 +51,60 @@ export function langName(code: string | null | undefined): string { if (!code) return "—"; 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 = { + 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; +} diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index aca7fc0..02dbc77 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -116,6 +116,17 @@ export interface PipelineReviewItem { file_path: string; // computed 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. */