From 0c747a92753c22cf0a516083495b79c794b359c8 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 12 Jul 2026 16:53:04 -0400 Subject: [PATCH] fix(webhooks): ignore episode-level Plex TMDB GUIDs when parsing scrobble events (#53) Plex attaches TMDB GUIDs that identify the individual episode, not the parent series, so using them as `tmdbId` caused incorrect series lookups. Now only movie scrobbles populate `tmdbId` from the TMDB GUID; episode scrobbles rely on IMDB/TVDB IDs (and the title) to resolve the show via `resolveShowTmdbId`. - Extract IMDB/TMDB/TVDB prefix constants to avoid magic strings - Add `parsePlexPayload` test asserting `tmdbId` is `undefined` for episode events - Add `processWebhook` integration test verifying `resolveShowTmdbId` is called without a TMDB ID for Plex episodes - Hoist `makePlexForm` helper to module scope so it's available across all describe blocks --- packages/core/src/webhooks.ts | 15 ++++-- packages/core/test/webhooks.test.ts | 71 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/core/src/webhooks.ts b/packages/core/src/webhooks.ts index 2bb383c..700bf82 100644 --- a/packages/core/src/webhooks.ts +++ b/packages/core/src/webhooks.ts @@ -13,6 +13,10 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking"; const log = createLogger("webhooks"); +const IMDB_GUID_PREFIX = "imdb://"; +const TMDB_GUID_PREFIX = "tmdb://"; +const TVDB_GUID_PREFIX = "tvdb://"; + // ─── Types ────────────────────────────────────────────────────────── export interface WebhookEvent { @@ -71,9 +75,14 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null { if (Array.isArray(guids)) { for (const g of guids) { const id = g.id ?? ""; - if (id.startsWith("tmdb://")) tmdbId = toOptionalInt(id.slice(7)); - else if (id.startsWith("imdb://")) imdbId = id.slice(7); - else if (id.startsWith("tvdb://")) tvdbId = id.slice(7); + if (id.startsWith(TMDB_GUID_PREFIX)) { + // Plex episode TMDB GUIDs identify episodes, not series. + if (isMovie) tmdbId = toOptionalInt(id.slice(TMDB_GUID_PREFIX.length)); + } else if (id.startsWith(IMDB_GUID_PREFIX)) { + imdbId = id.slice(IMDB_GUID_PREFIX.length); + } else if (id.startsWith(TVDB_GUID_PREFIX)) { + tvdbId = id.slice(TVDB_GUID_PREFIX.length); + } } } diff --git a/packages/core/test/webhooks.test.ts b/packages/core/test/webhooks.test.ts index ae6f250..e9fea8f 100644 --- a/packages/core/test/webhooks.test.ts +++ b/packages/core/test/webhooks.test.ts @@ -54,6 +54,12 @@ beforeEach(() => { mockGetTvDetails.mockReset().mockResolvedValue({ number_of_seasons: 1 }); }); +function makePlexForm(payload: Record): FormData { + const form = new FormData(); + form.set("payload", JSON.stringify(payload)); + return form; +} + // ─── toOptionalInt ────────────────────────────────────────────────── describe("toOptionalInt", () => { @@ -86,12 +92,6 @@ describe("toOptionalInt", () => { // ─── parsePlexPayload ─────────────────────────────────────────────── describe("parsePlexPayload", () => { - function makePlexForm(payload: Record): FormData { - const form = new FormData(); - form.set("payload", JSON.stringify(payload)); - return form; - } - test("parses a movie scrobble event", () => { const result = parsePlexPayload( makePlexForm({ @@ -143,6 +143,33 @@ describe("parsePlexPayload", () => { }); }); + test("ignores episode-level Plex TMDB GUIDs", () => { + const result = parsePlexPayload( + makePlexForm({ + event: "media.scrobble", + Metadata: { + type: "episode", + title: "Episode Title", + grandparentTitle: "The Pitt", + parentIndex: 1, + index: 14, + Guid: [{ id: "imdb://tt35740476" }, { id: "tmdb://6951292" }, { id: "tvdb://11560129" }], + }, + }), + ); + expect(result).toEqual({ + provider: "plex", + mediaType: "episode", + title: "Episode Title", + tmdbId: undefined, + imdbId: "tt35740476", + tvdbId: "11560129", + seasonNumber: 1, + episodeNumber: 14, + showTitle: "The Pitt", + }); + }); + test("returns null for non-scrobble events", () => { expect(parsePlexPayload(makePlexForm({ event: "media.play" }))).toBeNull(); }); @@ -451,6 +478,38 @@ describe("processWebhook", () => { expect(result.status).toBe("success"); }); + test("resolves parsed Plex episodes without passing the episode TMDB ID", async () => { + mockResolveShowTmdbId.mockResolvedValue(1396); + const { titleId } = insertTvShow("tv-1", 1396, 1, 14, { title: "The Pitt" }); + mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: titleId }); + + const event = parsePlexPayload( + makePlexForm({ + event: "media.scrobble", + Metadata: { + type: "episode", + title: "Episode Title", + grandparentTitle: "The Pitt", + parentIndex: 1, + index: 14, + Guid: [{ id: "imdb://tt35740476" }, { id: "tmdb://6951292" }, { id: "tvdb://11560129" }], + }, + }), + ); + expect(event).not.toBeNull(); + if (!event) throw new Error("Expected Plex episode event"); + + const result = await processWebhook(connectionId, userId, "plex", event); + + expect(result.status).toBe("success"); + expect(mockResolveShowTmdbId).toHaveBeenCalledWith({ + tmdbId: undefined, + imdbId: "tt35740476", + tvdbId: "11560129", + title: "The Pitt", + }); + }); + test("returns error when episode cannot be resolved", async () => { mockResolveShowTmdbId.mockResolvedValue(null);