done column: double-checkmark when jellyfin webhook corroborates the plan
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m37s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m37s
adds review_plans.webhook_verified, set to 1 whenever a fresh analysis (scan or post-execute webhook) sees is_noop=1, cleared if a webhook later flips the plan off-noop. resurrected the try/catch alter table migration pattern in server/db/index.ts for the new column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "netfelix-audio-fix",
|
"name": "netfelix-audio-fix",
|
||||||
"version": "2026.04.14.13",
|
"version": "2026.04.14.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||||
"dev:client": "vite",
|
"dev:client": "vite",
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ app.get("/pipeline", (c) => {
|
|||||||
const done = db
|
const done = db
|
||||||
.prepare(`
|
.prepare(`
|
||||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||||
rp.job_type, rp.apple_compat
|
rp.job_type, rp.apple_compat, rp.webhook_verified
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
JOIN media_items mi ON mi.id = j.item_id
|
JOIN media_items mi ON mi.id = j.item_id
|
||||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||||
|
|||||||
@@ -54,10 +54,28 @@ export function getDb(): Database {
|
|||||||
if (_db) return _db;
|
if (_db) return _db;
|
||||||
_db = new Database(dbPath, { create: true });
|
_db = new Database(dbPath, { create: true });
|
||||||
_db.exec(SCHEMA);
|
_db.exec(SCHEMA);
|
||||||
|
migrate(_db);
|
||||||
seedDefaults(_db);
|
seedDefaults(_db);
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent ALTER TABLE migrations for columns added after the initial
|
||||||
|
* CREATE TABLE ships. Each block swallows "duplicate column" errors so the
|
||||||
|
* same code path is safe on fresh and existing databases. Do not remove old
|
||||||
|
* migrations — databases in the wild may be several versions behind.
|
||||||
|
*/
|
||||||
|
function migrate(db: Database): void {
|
||||||
|
const alter = (sql: string) => {
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
} catch (_err) {
|
||||||
|
// column already present — ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
alter("ALTER TABLE review_plans ADD COLUMN webhook_verified INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
|
||||||
function seedDefaults(db: Database): void {
|
function seedDefaults(db: Database): void {
|
||||||
const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
|
const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
|
||||||
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ CREATE TABLE IF NOT EXISTS review_plans (
|
|||||||
subs_extracted INTEGER NOT NULL DEFAULT 0,
|
subs_extracted INTEGER NOT NULL DEFAULT 0,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
|
-- Jellyfin has independently re-probed the file and the fresh analysis
|
||||||
|
-- still says is_noop=1. Set on initial scan for items that never needed
|
||||||
|
-- work, or on a post-execute webhook that corroborates the on-disk state.
|
||||||
|
-- Surfaces as the second checkmark on the Done column.
|
||||||
|
webhook_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -180,3 +180,61 @@ describe("processWebhookEvent — done-status override", () => {
|
|||||||
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processWebhookEvent — webhook_verified flag", () => {
|
||||||
|
beforeEach(() => _resetDedupe());
|
||||||
|
|
||||||
|
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
||||||
|
return processWebhookEvent(
|
||||||
|
{ event: "ItemUpdated", itemId: item.Id, itemType: item.Type as "Movie" | "Episode" },
|
||||||
|
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifiedFor(db: Database, jellyfinId: string): number {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT rp.webhook_verified as v FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?",
|
||||||
|
)
|
||||||
|
.get(jellyfinId) as { v: number }
|
||||||
|
).v;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("is_noop=1 on first scan sets webhook_verified=1 (no Jellyfin round-trip needed)", async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const fresh = fakeItem();
|
||||||
|
await runWebhook(db, fresh);
|
||||||
|
expect(verifiedFor(db, fresh.Id)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a post-execute webhook that still says is_noop=1 keeps webhook_verified=1", async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const fresh = fakeItem();
|
||||||
|
await runWebhook(db, fresh);
|
||||||
|
_resetDedupe();
|
||||||
|
await runWebhook(db, fresh);
|
||||||
|
expect(verifiedFor(db, fresh.Id)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("webhook that flips plan off-noop clears webhook_verified back to 0", async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const noopItem = fakeItem();
|
||||||
|
await runWebhook(db, noopItem);
|
||||||
|
expect(verifiedFor(db, noopItem.Id)).toBe(1);
|
||||||
|
|
||||||
|
// Second probe: Jellyfin reports a drifted file (extra french track
|
||||||
|
// that the 'deu' language config would now remove → is_noop=0).
|
||||||
|
const driftedCfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] };
|
||||||
|
const drifted = fakeItem({
|
||||||
|
MediaStreams: [
|
||||||
|
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||||
|
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||||
|
{ Type: "Audio", Index: 2, Codec: "aac", Language: "fra" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
_resetDedupe();
|
||||||
|
await runWebhook(db, drifted, driftedCfg);
|
||||||
|
expect(verifiedFor(db, noopItem.Id)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -229,10 +229,16 @@ export async function upsertJellyfinItem(
|
|||||||
// commit that made done terminal)
|
// commit that made done terminal)
|
||||||
// error → pending (retry loop)
|
// error → pending (retry loop)
|
||||||
// else keep current status
|
// else keep current status
|
||||||
|
//
|
||||||
|
// webhook_verified tracks whether Jellyfin's view of the file agrees
|
||||||
|
// with ours. It's set to 1 whenever is_noop=1 (scan or webhook both
|
||||||
|
// count — an unchanged file is already in the desired end state and
|
||||||
|
// needs no further confirmation). It's cleared back to 0 the moment
|
||||||
|
// a webhook says the file drifted off-noop.
|
||||||
db
|
db
|
||||||
.prepare(`
|
.prepare(`
|
||||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes, webhook_verified)
|
||||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(item_id) DO UPDATE SET
|
ON CONFLICT(item_id) DO UPDATE SET
|
||||||
status = CASE
|
status = CASE
|
||||||
WHEN excluded.is_noop = 1 THEN 'done'
|
WHEN excluded.is_noop = 1 THEN 'done'
|
||||||
@@ -245,7 +251,12 @@ export async function upsertJellyfinItem(
|
|||||||
confidence = excluded.confidence,
|
confidence = excluded.confidence,
|
||||||
apple_compat = excluded.apple_compat,
|
apple_compat = excluded.apple_compat,
|
||||||
job_type = excluded.job_type,
|
job_type = excluded.job_type,
|
||||||
notes = excluded.notes
|
notes = excluded.notes,
|
||||||
|
webhook_verified = CASE
|
||||||
|
WHEN excluded.is_noop = 1 THEN 1
|
||||||
|
WHEN ? = 'webhook' THEN 0
|
||||||
|
ELSE review_plans.webhook_verified
|
||||||
|
END
|
||||||
`)
|
`)
|
||||||
.run(
|
.run(
|
||||||
itemId,
|
itemId,
|
||||||
@@ -254,6 +265,8 @@ export async function upsertJellyfinItem(
|
|||||||
analysis.apple_compat,
|
analysis.apple_compat,
|
||||||
analysis.job_type,
|
analysis.job_type,
|
||||||
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
||||||
|
analysis.is_noop ? 1 : 0,
|
||||||
|
source,
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface ReviewPlan {
|
|||||||
subs_extracted: number;
|
subs_extracted: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
|
webhook_verified: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,33 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) {
|
|||||||
count={items.length}
|
count={items.length}
|
||||||
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
|
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<div key={item.id} className="rounded border bg-white p-2">
|
const verified = item.status === "done" && item.webhook_verified === 1;
|
||||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
const mark = verified ? "✓✓" : item.status === "done" ? "✓" : "✗";
|
||||||
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
const markTitle = verified
|
||||||
</div>
|
? "Done — Jellyfin re-probe confirms the file matches the plan"
|
||||||
))}
|
: item.status === "done"
|
||||||
|
? "Done — awaiting Jellyfin webhook confirmation"
|
||||||
|
: "Error";
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="rounded border bg-white p-2">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span
|
||||||
|
title={markTitle}
|
||||||
|
className={`font-mono text-xs shrink-0 ${
|
||||||
|
verified ? "text-green-700" : item.status === "done" ? "text-gray-400" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mark}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||||
|
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
||||||
</ColumnShell>
|
</ColumnShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ export interface PipelineJobItem {
|
|||||||
file_path?: string;
|
file_path?: string;
|
||||||
confidence?: "high" | "low";
|
confidence?: "high" | "low";
|
||||||
is_noop?: number;
|
is_noop?: number;
|
||||||
|
// 1 when Jellyfin's independent re-probe agrees the on-disk file is
|
||||||
|
// already in the desired end state. Renders as a second checkmark in
|
||||||
|
// the Done column.
|
||||||
|
webhook_verified?: number;
|
||||||
transcode_reasons?: string[];
|
transcode_reasons?: string[];
|
||||||
audio_streams?: PipelineAudioStream[];
|
audio_streams?: PipelineAudioStream[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user