d05e037bbc
Build and Push Docker Image / build (push) Successful in 1m56s
monitoring the mqtt broker revealed two bugs and one design dead-end: 1. the jellyfin-plugin-webhook publishes pascalcase fields (NotificationType, ItemId, ItemType) and we were reading camelcase (event, itemId, itemType). every real payload was rejected by the first guard — the mqtt path never ingested anything. 2. the plugin has no ItemUpdated / Library.* notifications. file rewrites on existing items produce zero broker traffic (observed: transcode + manual refresh metadata + 'recently added' appearance → no mqtt messages). ✓✓ via webhook is structurally impossible. fix the webhook path so brand-new library items actually get ingested, and narrow ACCEPTED_EVENTS to just 'ItemAdded' (the only library-side event the plugin emits). move the ✓✓ signal from webhook-corroboration to post-execute ffprobe via the existing verifyDesiredState helper: after ffmpeg returns 0 we probe the output file ourselves and flip verified=1 on match. the preflight-skipped path sets verified=1 too. renamed the db column webhook_verified → verified (via idempotent RENAME COLUMN migration) since the signal is no longer webhook-sourced, and updated the Done column tooltip to reflect that ffprobe is doing the verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { SCHEMA } from "../../db/schema";
|
|
import type { JellyfinItem } from "../../types";
|
|
import type { JellyfinConfig } from "../jellyfin";
|
|
import type { RescanConfig } from "../rescan";
|
|
import { _resetDedupe, processWebhookEvent } from "../webhook";
|
|
|
|
function makeDb(): Database {
|
|
const db = new Database(":memory:");
|
|
for (const stmt of SCHEMA.split(";")) {
|
|
const trimmed = stmt.trim();
|
|
if (trimmed) db.run(trimmed);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
|
|
const RESCAN_CFG: RescanConfig = {
|
|
audioLanguages: [],
|
|
radarr: null,
|
|
sonarr: null,
|
|
radarrLibrary: null,
|
|
sonarrLibrary: null,
|
|
};
|
|
|
|
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
|
return {
|
|
Id: "jf-1",
|
|
Type: "Movie",
|
|
Name: "Test Movie",
|
|
Path: "/movies/Test.mkv",
|
|
Container: "mkv",
|
|
MediaStreams: [
|
|
{ Type: "Video", Index: 0, Codec: "h264" },
|
|
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("processWebhookEvent — acceptance", () => {
|
|
beforeEach(() => _resetDedupe());
|
|
afterEach(() => _resetDedupe());
|
|
|
|
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
|
|
const db = makeDb();
|
|
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
|
|
const res = await processWebhookEvent(
|
|
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
|
|
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
|
);
|
|
expect(res.accepted).toBe(false);
|
|
expect(res.reason).toContain("NotificationType");
|
|
}
|
|
});
|
|
|
|
test("rejects non-Movie/Episode types", async () => {
|
|
const db = makeDb();
|
|
const res = await processWebhookEvent(
|
|
{ NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Trailer" },
|
|
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
|
|
);
|
|
expect(res.accepted).toBe(false);
|
|
expect(res.reason).toContain("ItemType");
|
|
});
|
|
|
|
test("rejects missing ItemId", async () => {
|
|
const db = makeDb();
|
|
const res = await processWebhookEvent(
|
|
{ NotificationType: "ItemAdded", ItemType: "Movie" },
|
|
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
|
);
|
|
expect(res.accepted).toBe(false);
|
|
expect(res.reason).toContain("ItemId");
|
|
});
|
|
|
|
test("dedupes bursts within 5s and accepts again after", async () => {
|
|
const db = makeDb();
|
|
let fakeNow = 1_000_000;
|
|
const getItemFn = async () => fakeItem();
|
|
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
|
|
|
|
const first = await processWebhookEvent(payload, {
|
|
db,
|
|
jellyfin: JF,
|
|
rescanCfg: RESCAN_CFG,
|
|
getItemFn,
|
|
now: () => fakeNow,
|
|
});
|
|
expect(first.accepted).toBe(true);
|
|
|
|
fakeNow += 1000;
|
|
const second = await processWebhookEvent(payload, {
|
|
db,
|
|
jellyfin: JF,
|
|
rescanCfg: RESCAN_CFG,
|
|
getItemFn,
|
|
now: () => fakeNow,
|
|
});
|
|
expect(second.accepted).toBe(false);
|
|
expect(second.reason).toBe("deduped");
|
|
|
|
fakeNow += 5001;
|
|
const third = await processWebhookEvent(payload, {
|
|
db,
|
|
jellyfin: JF,
|
|
rescanCfg: RESCAN_CFG,
|
|
getItemFn,
|
|
now: () => fakeNow,
|
|
});
|
|
expect(third.accepted).toBe(true);
|
|
});
|
|
|
|
test("drops when Jellyfin returns no item", async () => {
|
|
const db = makeDb();
|
|
const res = await processWebhookEvent(
|
|
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
|
|
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
|
|
);
|
|
expect(res.accepted).toBe(false);
|
|
expect(res.reason).toContain("no item");
|
|
});
|
|
});
|
|
|
|
describe("processWebhookEvent — done-status override", () => {
|
|
beforeEach(() => _resetDedupe());
|
|
|
|
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
|
return processWebhookEvent(
|
|
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
|
|
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
|
);
|
|
}
|
|
|
|
function planStatusFor(db: Database, jellyfinId: string): string {
|
|
return (
|
|
db
|
|
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
|
|
.get(jellyfinId) as { status: string }
|
|
).status;
|
|
}
|
|
|
|
test("a webhook that analyzes to is_noop=1 leaves a done plan as done", async () => {
|
|
const db = makeDb();
|
|
const fresh = fakeItem();
|
|
await runWebhook(db, fresh);
|
|
|
|
db
|
|
.prepare(
|
|
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
|
)
|
|
.run(fresh.Id);
|
|
|
|
_resetDedupe();
|
|
await runWebhook(db, fresh);
|
|
expect(planStatusFor(db, fresh.Id)).toBe("done");
|
|
});
|
|
|
|
test("a webhook that analyzes to is_noop=0 flips a done plan back to pending", async () => {
|
|
const db = makeDb();
|
|
// audio_languages=['deu'] means a file with english OG + french extra
|
|
// audio should remove french → is_noop=0.
|
|
const cfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] };
|
|
const fresh = 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" },
|
|
],
|
|
});
|
|
|
|
await runWebhook(db, fresh, cfg);
|
|
db
|
|
.prepare(
|
|
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
|
)
|
|
.run(fresh.Id);
|
|
|
|
_resetDedupe();
|
|
await runWebhook(db, fresh, cfg);
|
|
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(
|
|
{ NotificationType: "ItemAdded", 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.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);
|
|
});
|
|
});
|