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",
|
"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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user