From 50e1ea66f4973610c2500adf99f275a8daa63175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 21 Apr 2026 11:01:54 +0200 Subject: [PATCH] show processing reasons as pills on pipeline cards 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) --- package.json | 2 +- server/api/review.ts | 48 ++++++++++++-------------- server/db/index.ts | 1 + server/db/schema.ts | 1 + server/services/analyzer.ts | 21 +++++------ server/types.ts | 2 ++ src/features/pipeline/PipelineCard.tsx | 20 +++++------ src/shared/lib/types.ts | 4 +-- 8 files changed, 50 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 33e38a1..950ac86 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/review.ts b/server/api/review.ts index 26d16a9..d0baf7f 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -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, 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(); - 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, 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 diff --git a/server/db/index.ts b/server/db/index.ts index 2d45417..37c781e 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -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). diff --git a/server/db/schema.ts b/server/db/schema.ts index 7a464f5..9f9e443 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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')) ); diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index 768d81e..4205341 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -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 }; } /** diff --git a/server/types.ts b/server/types.ts index 8e2d07c..3b7e75f 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 6ca8a03..e645739 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -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 (
@@ -184,13 +184,13 @@ export function PipelineCard({ {!minimal && hasInfoRow && (
- {hasTranscodeReasons - ? item.transcode_reasons!.map((r) => ( - - {r} - - )) - : item.job_type === "copy" && copy} + {item.job_type && {item.job_type}} + {hasReasons && + item.reasons!.map((r) => ( + + {r} + + ))}
diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 18be597..3be8953 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -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[]; }