show processing reasons as pills on pipeline cards
Build and Push Docker Image / build (push) Successful in 1m1s
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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user