From ff74cc3a048286b0da4a93c6591b66d949e033ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 14 Apr 2026 12:48:27 +0200 Subject: [PATCH] queue column: reuse review card read-only, back-to-review instead of approve 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. --- package.json | 2 +- server/api/review.ts | 60 +++++++++++++++----------- src/features/pipeline/PipelineCard.tsx | 45 +++++++++++++------ src/features/pipeline/PipelinePage.tsx | 2 +- src/features/pipeline/QueueColumn.tsx | 23 ++++++---- src/shared/lib/types.ts | 16 +++++++ 6 files changed, 101 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 2e084c9..1141297 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/review.ts b/server/api/review.ts index 72f2520..bc34f6d 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -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(); - 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(); 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(); - 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(); 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 }); }); diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 9e33017..e57157d 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -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 & { - 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 )} + {onUnapprove && ( + + )} ); diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 3e1427b..243a3c3 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -91,7 +91,7 @@ export function PipelinePage() {
- +
diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index 6c0c0bb..1322afe 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -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 ( 0 ? [{ label: "Clear", onClick: clear }] : undefined} > - {items.map((item) => ( -
-

{item.name}

- {item.job_type} -
- ))} - {items.length === 0 &&

Queue empty

} +
+ {items.map((item) => ( + unapprove(item.item_id)} /> + ))} + {items.length === 0 &&

Queue empty

} +
); } diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 02dbc77..9347ab7 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -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 {