show processing reasons as pills on pipeline cards
Build and Push Docker Image / build (push) Successful in 1m1s

Analyzer now computes structured reason tags (Remove tracks, Reorder,
Extract subs, Transcode, Fix default, Fix language tag, Fix title) and
stores them as JSON in review_plans.reasons. Pipeline cards show these
as badges next to the copy/transcode pill so users know why a file
needs processing. Replaces the old transcode_reasons computed from
stream_decisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 11:01:54 +02:00
parent 0a8996dc33
commit 50e1ea66f4
8 changed files with 50 additions and 49 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.21.10",
"version": "2026.04.21.11",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",
+22 -26
View File
@@ -396,15 +396,16 @@ export function reanalyze(
db
.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, auto_class, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
INSERT INTO review_plans (item_id, status, is_noop, auto_class, apple_compat, job_type, notes, reasons)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = 'pending',
is_noop = excluded.is_noop,
auto_class = excluded.auto_class,
apple_compat = excluded.apple_compat,
job_type = excluded.job_type,
notes = excluded.notes
notes = excluded.notes,
reasons = excluded.reasons
`)
.run(
itemId,
@@ -413,6 +414,7 @@ export function reanalyze(
analysis.apple_compat,
analysis.job_type,
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
analysis.reasons.length > 0 ? JSON.stringify(analysis.reasons) : null,
);
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
@@ -528,35 +530,28 @@ interface PipelineAudioStream {
action: "keep" | "remove";
}
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
type EnrichableRow = { id?: number; plan_id?: number; item_id: number; reasons_raw?: string | null } & {
reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
/**
* Enrich review/queued rows with transcode-reason badges and pre-checked audio
* streams. Works for both the Review column (where `id` is the plan id) and
* Enrich review/queued rows with reason badges and pre-checked audio streams.
* Reasons come from the stored review_plans.reasons JSON column (set by the
* analyzer). Works for both the Review column (where `id` is the plan id) and
* the Queued column (where `plan_id` is explicit and `id` is the job id).
*/
function enrichWithStreamsAndReasons(db: ReturnType<typeof getDb>, rows: EnrichableRow[]): void {
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 (${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()}`);
// Parse stored reasons JSON
for (const r of rows) {
try {
r.reasons = r.reasons_raw ? JSON.parse(r.reasons_raw) : [];
} catch {
r.reasons = [];
}
}
const streamPh = itemIds.map(() => "?").join(",");
@@ -595,7 +590,6 @@ function enrichWithStreamsAndReasons(db: ReturnType<typeof getDb>, rows: Enricha
}
for (const r of rows) {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? [];
}
}
@@ -620,7 +614,8 @@ interface ReviewItemRow {
original_language: string | null;
orig_lang_source: string | null;
file_path: string;
transcode_reasons?: string[];
reasons_raw?: string | null;
reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
@@ -662,7 +657,8 @@ export function buildReviewGroups(
const order = orderClause(opts.sort ?? defaultSort);
const rows = db
.prepare(`
SELECT rp.*, mi.name, mi.series_name, mi.series_key,
SELECT rp.*, rp.reasons as reasons_raw,
mi.name, mi.series_name, mi.series_key,
mi.season_number, mi.episode_number, mi.type, mi.container,
mi.original_language, mi.orig_lang_source, mi.file_path,
mi.last_scanned_at
@@ -838,7 +834,7 @@ app.get("/pipeline", (c) => {
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.auto_class, rp.is_noop
rp.auto_class, rp.is_noop, rp.reasons as reasons_raw
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
+1
View File
@@ -86,6 +86,7 @@ function migrate(db: Database): void {
// Indexes for new columns — must run after the columns exist on existing DBs
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)");
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)");
alter("ALTER TABLE review_plans ADD COLUMN reasons TEXT");
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
// drop-jellyfin: if old schema detected, wipe all tables so SCHEMA
// recreates them with the new structure (file_path UNIQUE, no jellyfin columns).
+1
View File
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS review_plans (
job_type TEXT NOT NULL DEFAULT 'copy',
subs_extracted INTEGER NOT NULL DEFAULT 0,
notes TEXT,
reasons TEXT,
reviewed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+11 -10
View File
@@ -123,15 +123,16 @@ export function analyzeItem(
return expected != null && s.title !== expected;
});
const is_noop =
!anyAudioRemoved &&
!audioOrderChanged &&
!hasSubs &&
!needsTranscode &&
!defaultMismatch &&
!nonDefaultHasDefault &&
!languageMismatch &&
!titleMismatch;
const reasons: string[] = [];
if (anyAudioRemoved) reasons.push("Remove tracks");
if (audioOrderChanged) reasons.push("Reorder");
if (hasSubs) reasons.push("Extract subs");
if (needsTranscode) reasons.push("Transcode");
if (defaultMismatch || nonDefaultHasDefault) reasons.push("Fix default");
if (languageMismatch) reasons.push("Fix language tag");
if (titleMismatch) reasons.push("Fix title");
const is_noop = reasons.length === 0;
if (!origLang && item.needs_review) {
notes.push("Original language unknown — audio tracks not filtered; manual review required");
@@ -203,7 +204,7 @@ export function analyzeItem(
auto_class = "auto";
}
return { is_noop, has_subs: hasSubs, auto_class, apple_compat, job_type, decisions, notes };
return { is_noop, has_subs: hasSubs, auto_class, apple_compat, job_type, decisions, notes, reasons };
}
/**
+2
View File
@@ -108,6 +108,8 @@ export interface PlanResult {
transcode_codec: string | null;
}>;
notes: string[];
/** Short reason tags explaining why the file needs processing. */
reasons: string[];
}
// ─── Scan state ───────────────────────────────────────────────────────────────
+7 -7
View File
@@ -17,7 +17,7 @@ interface PipelineCardItem {
auto_class?: PipelineReviewItem["auto_class"];
job_type?: "copy" | "transcode";
original_language?: string | null;
transcode_reasons?: string[];
reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
@@ -108,8 +108,8 @@ export function PipelineCard({
const mediaItemId: number = item.item_id ?? (item as { id: number }).id;
const hasActionRow = !!(onSkip || onApprove || onUnapprove || onApproveUpToHere || onProcess);
const hasTranscodeReasons = !!item.transcode_reasons && item.transcode_reasons.length > 0;
const hasInfoRow = hasTranscodeReasons || item.job_type === "copy" || !!item.auto_class;
const hasReasons = !!item.reasons && item.reasons.length > 0;
const hasInfoRow = hasReasons || !!item.job_type || !!item.auto_class;
return (
<div className={`group rounded-lg border p-3 ${cardColor}`}>
@@ -184,13 +184,13 @@ export function PipelineCard({
{!minimal && hasInfoRow && (
<div className="flex items-center justify-between gap-2 mt-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
{hasTranscodeReasons
? item.transcode_reasons!.map((r) => (
{item.job_type && <Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>}
{hasReasons &&
item.reasons!.map((r) => (
<Badge key={r} variant="manual">
{r}
</Badge>
))
: item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
))}
</div>
<AutoClassBadge autoClass={item.auto_class ?? null} />
</div>
+2 -2
View File
@@ -106,7 +106,7 @@ export interface PipelineReviewItem {
orig_lang_source: string | null;
file_path: string;
// computed
transcode_reasons: string[];
reasons: string[];
audio_streams: PipelineAudioStream[];
}
@@ -145,7 +145,7 @@ export interface PipelineJobItem {
file_path?: string;
auto_class?: "auto" | "auto_heuristic" | "manual" | null;
is_noop?: number;
transcode_reasons?: string[];
reasons?: string[];
audio_streams?: PipelineAudioStream[];
}