webhook: PascalCase payload + ItemAdded only, switch ✓✓ signal to ffprobe
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>
This commit is contained in:
2026-04-14 17:27:22 +02:00
parent 9cdc054c4b
commit d05e037bbc
11 changed files with 94 additions and 60 deletions
+19 -17
View File
@@ -43,41 +43,43 @@ describe("processWebhookEvent — acceptance", () => {
beforeEach(() => _resetDedupe());
afterEach(() => _resetDedupe());
test("rejects unknown events", async () => {
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ event: "PlaybackStart", itemId: "jf-1", itemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("event");
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(
{ event: "ItemUpdated", itemId: "jf-1", itemType: "Trailer" },
{ 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");
expect(res.reason).toContain("ItemType");
});
test("rejects missing itemId", async () => {
test("rejects missing ItemId", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ event: "ItemUpdated", itemType: "Movie" },
{ NotificationType: "ItemAdded", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("itemId");
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 = { event: "ItemUpdated", itemId: "jf-1", itemType: "Movie" };
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
const first = await processWebhookEvent(payload, {
db,
@@ -113,7 +115,7 @@ describe("processWebhookEvent — acceptance", () => {
test("drops when Jellyfin returns no item", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ event: "ItemUpdated", itemId: "jf-missing", itemType: "Movie" },
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
);
expect(res.accepted).toBe(false);
@@ -126,7 +128,7 @@ describe("processWebhookEvent — done-status override", () => {
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
return processWebhookEvent(
{ event: "ItemUpdated", itemId: item.Id, itemType: item.Type as "Movie" | "Episode" },
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
);
}
@@ -186,7 +188,7 @@ describe("processWebhookEvent — webhook_verified flag", () => {
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
return processWebhookEvent(
{ event: "ItemUpdated", itemId: item.Id, itemType: item.Type as "Movie" | "Episode" },
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
);
}
@@ -195,7 +197,7 @@ describe("processWebhookEvent — webhook_verified flag", () => {
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 = ?",
"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;