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
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:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user