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
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:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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<PipelineReviewItem, "item_id" | "transcode_reasons"> & {
|
||||
| (Omit<PipelineReviewItem, "item_id" | "transcode_reasons" | "audio_streams"> & {
|
||||
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
|
||||
<p className="text-sm font-medium truncate">{title}</p>
|
||||
)}
|
||||
<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.map((r) => (
|
||||
<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>}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user