queue column: reuse review card read-only, back-to-review instead of approve
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m52s

Queued jobs now render the full pipeline card with locked-in audio
stream checkboxes and transcode badges, so the rationale for queuing
stays visible. The primary action becomes "Back to review" which
unapproves the plan and moves the item back to the Review column.
This commit is contained in:
2026-04-14 12:48:27 +02:00
parent 4057b692ba
commit ff74cc3a04
6 changed files with 101 additions and 47 deletions

View File

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

View File

@@ -290,10 +290,16 @@ app.get("/pipeline", (c) => {
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
).n;
// Queued gets the same enrichment as review so the card can render
// streams + transcode reasons read-only (with a "Back to review" button).
const queued = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
SELECT j.id, j.item_id, j.status, j.started_at, j.completed_at,
mi.name, mi.series_name, mi.series_jellyfin_id, mi.jellyfin_id,
mi.season_number, mi.episode_number, mi.type, mi.container,
mi.original_language, mi.orig_lang_source, mi.file_path,
rp.id as plan_id, rp.job_type, rp.apple_compat,
rp.confidence, rp.is_noop
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
@@ -336,35 +342,35 @@ app.get("/pipeline", (c) => {
};
const doneCount = noopRow.n + doneRow.n;
// Batch transcode reasons for all review plans in one query (avoids N+1)
const planIds = (review as { id: number }[]).map((r) => r.id);
const reasonsByPlan = new Map<number, string[]>();
if (planIds.length > 0) {
const placeholders = planIds.map(() => "?").join(",");
// Enrich rows that have (plan_id, item_id) with the transcode-reason
// badges and pre-checked audio streams. Used for both review and queued
// columns so the queued card can render read-only with the same info.
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => {
if (rows.length === 0) return;
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
const planIds = rows.map(planIdFor);
const itemIds = rows.map((r) => r.item_id);
const reasonPh = planIds.map(() => "?").join(",");
const allReasons = db
.prepare(`
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL
WHERE sd.plan_id IN (${reasonPh}) AND sd.transcode_codec IS NOT NULL
`)
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
const reasonsByPlan = new Map<number, string[]>();
for (const r of allReasons) {
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()}${r.transcode_codec.toUpperCase()}`);
}
}
for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
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 streamPh = itemIds.map(() => "?").join(",");
const streamRows = db
.prepare(`
SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title,
@@ -372,7 +378,7 @@ app.get("/pipeline", (c) => {
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'
WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio'
ORDER BY ms.item_id, ms.stream_index
`)
.all(...itemIds) as {
@@ -385,6 +391,7 @@ app.get("/pipeline", (c) => {
is_default: number;
action: "keep" | "remove" | null;
}[];
const streamsByItem = new Map<number, PipelineAudioStream[]>();
for (const r of streamRows) {
if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []);
streamsByItem.get(r.item_id)!.push({
@@ -397,10 +404,15 @@ app.get("/pipeline", (c) => {
action: r.action ?? "keep",
});
}
}
for (const item of review as { item_id: number; audio_streams?: PipelineAudioStream[] }[]) {
item.audio_streams = streamsByItem.get(item.item_id) ?? [];
}
for (const r of rows) {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? [];
}
};
enrichWithStreamsAndReasons(review as EnrichableRow[]);
enrichWithStreamsAndReasons(queued as EnrichableRow[]);
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
});

View File

@@ -1,18 +1,26 @@
import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge";
import { langName, normalizeLanguageClient } from "~/shared/lib/lang";
import type { PipelineAudioStream, PipelineReviewItem } from "~/shared/lib/types";
import type { PipelineAudioStream } 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" | "audio_streams"> & {
id: number;
item_id?: number;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
});
// Shared shape across review items, raw media_item rows, and queued jobs.
// Only name/type are strictly required; the rest is optional so the card
// can render from any of those sources.
interface PipelineCardItem {
id?: number;
item_id?: number;
name: string;
type: "Movie" | "Episode";
series_name?: string | null;
season_number?: number | null;
episode_number?: number | null;
jellyfin_id?: string;
confidence?: "high" | "low";
job_type?: "copy" | "transcode";
original_language?: string | null;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
interface PipelineCardProps {
item: PipelineCardItem;
@@ -20,6 +28,10 @@ interface PipelineCardProps {
onToggleStream?: (streamId: number, nextAction: "keep" | "remove") => void;
onApprove?: () => void;
onSkip?: () => void;
// When present, the card renders in queue mode: streams are locked in
// (no onToggleStream) and the primary button un-approves the plan,
// sending the item back to the Review column.
onUnapprove?: () => void;
}
function describeStream(s: PipelineAudioStream): string {
@@ -35,7 +47,7 @@ function describeStream(s: PipelineAudioStream): string {
return parts.join(" · ");
}
export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onSkip }: PipelineCardProps) {
export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onSkip, onUnapprove }: PipelineCardProps) {
const title =
item.type === "Episode"
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}${item.name}`
@@ -141,6 +153,15 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS
Approve
</button>
)}
{onUnapprove && (
<button
type="button"
onClick={onUnapprove}
className="text-xs px-3 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
>
Back to review
</button>
)}
</div>
</div>
);

View File

@@ -91,7 +91,7 @@ export function PipelinePage() {
</div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
<QueueColumn items={data.queued} onMutate={load} />
<QueueColumn items={data.queued} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
<DoneColumn items={data.done} onMutate={load} />
</div>

View File

@@ -1,33 +1,38 @@
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
interface QueueColumnProps {
items: PipelineJobItem[];
jellyfinUrl: string;
onMutate: () => void;
}
export function QueueColumn({ items, onMutate }: QueueColumnProps) {
export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps) {
const clear = async () => {
if (!confirm(`Cancel all ${items.length} pending jobs?`)) return;
await api.post("/api/execute/clear");
onMutate();
};
const unapprove = async (itemId: number) => {
await api.post(`/api/review/${itemId}/unapprove`);
onMutate();
};
return (
<ColumnShell
title="Queued"
count={items.length}
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
>
{items.map((item) => (
<div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
</div>
))}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
<div className="space-y-2">
{items.map((item) => (
<PipelineCard key={item.id} item={item} jellyfinUrl={jellyfinUrl} onUnapprove={() => unapprove(item.item_id)} />
))}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
</div>
</ColumnShell>
);
}

View File

@@ -141,6 +141,22 @@ export interface PipelineJobItem {
series_name: string | null;
type: "Movie" | "Episode";
apple_compat: ReviewPlan["apple_compat"];
// Queued jobs carry the full review context so the card can be reused
// read-only with a "Back to review" action. Optional because the
// Processing / Done columns don't enrich (and don't need to).
plan_id?: number;
series_jellyfin_id?: string | null;
jellyfin_id?: string;
season_number?: number | null;
episode_number?: number | null;
container?: string | null;
original_language?: string | null;
orig_lang_source?: string | null;
file_path?: string;
confidence?: "high" | "low";
is_noop?: number;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
export interface PipelineData {