diff --git a/package.json b/package.json index 1141297..32544de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.14.13", + "version": "2026.04.14.14", "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 bc34f6d..5cda94b 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -322,7 +322,7 @@ app.get("/pipeline", (c) => { const done = db .prepare(` 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 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 a4516f0..218c0e9 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -54,10 +54,28 @@ export function getDb(): Database { if (_db) return _db; _db = new Database(dbPath, { create: true }); _db.exec(SCHEMA); + migrate(_db); seedDefaults(_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 { const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)"); for (const [key, value] of Object.entries(DEFAULT_CONFIG)) { diff --git a/server/db/schema.ts b/server/db/schema.ts index ad9b2f0..1e4a639 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -70,6 +70,11 @@ CREATE TABLE IF NOT EXISTS review_plans ( subs_extracted INTEGER NOT NULL DEFAULT 0, notes 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')) ); diff --git a/server/services/__tests__/webhook.test.ts b/server/services/__tests__/webhook.test.ts index bf30fa8..744ff3d 100644 --- a/server/services/__tests__/webhook.test.ts +++ b/server/services/__tests__/webhook.test.ts @@ -180,3 +180,61 @@ describe("processWebhookEvent — done-status override", () => { 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); + }); +}); diff --git a/server/services/rescan.ts b/server/services/rescan.ts index d259ab2..d3e8d6b 100644 --- a/server/services/rescan.ts +++ b/server/services/rescan.ts @@ -229,10 +229,16 @@ export async function upsertJellyfinItem( // commit that made done terminal) // error → pending (retry loop) // 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 .prepare(` - INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes) - VALUES (?, 'pending', ?, ?, ?, ?, ?) + INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes, webhook_verified) + VALUES (?, 'pending', ?, ?, ?, ?, ?, ?) ON CONFLICT(item_id) DO UPDATE SET status = CASE WHEN excluded.is_noop = 1 THEN 'done' @@ -245,7 +251,12 @@ export async function upsertJellyfinItem( confidence = excluded.confidence, apple_compat = excluded.apple_compat, 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( itemId, @@ -254,6 +265,8 @@ export async function upsertJellyfinItem( analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join("\n") : null, + analysis.is_noop ? 1 : 0, + source, source, ); diff --git a/server/types.ts b/server/types.ts index e881654..8e21f18 100644 --- a/server/types.ts +++ b/server/types.ts @@ -65,6 +65,7 @@ export interface ReviewPlan { subs_extracted: number; notes: string | null; reviewed_at: string | null; + webhook_verified: number; created_at: string; } diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index 9a2ffd7..45c5f88 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -20,12 +20,33 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) { count={items.length} actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined} > - {items.map((item) => ( -
{item.name}
-{item.name}
+No completed items
} ); diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 9347ab7..fb9ac01 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -155,6 +155,10 @@ export interface PipelineJobItem { file_path?: string; confidence?: "high" | "low"; 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[]; audio_streams?: PipelineAudioStream[]; }