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[]; }