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
This commit is contained in:
2026-07-12 16:53:04 -04:00
committed by GitHub
parent bb3b7891d4
commit 0c747a9275
2 changed files with 77 additions and 9 deletions
+12 -3
View File
@@ -13,6 +13,10 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking";
const log = createLogger("webhooks"); const log = createLogger("webhooks");
const IMDB_GUID_PREFIX = "imdb://";
const TMDB_GUID_PREFIX = "tmdb://";
const TVDB_GUID_PREFIX = "tvdb://";
// ─── Types ────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────
export interface WebhookEvent { export interface WebhookEvent {
@@ -71,9 +75,14 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
if (Array.isArray(guids)) { if (Array.isArray(guids)) {
for (const g of guids) { for (const g of guids) {
const id = g.id ?? ""; const id = g.id ?? "";
if (id.startsWith("tmdb://")) tmdbId = toOptionalInt(id.slice(7)); if (id.startsWith(TMDB_GUID_PREFIX)) {
else if (id.startsWith("imdb://")) imdbId = id.slice(7); // Plex episode TMDB GUIDs identify episodes, not series.
else if (id.startsWith("tvdb://")) tvdbId = id.slice(7); 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);
}
} }
} }
+65 -6
View File
@@ -54,6 +54,12 @@ beforeEach(() => {
mockGetTvDetails.mockReset().mockResolvedValue({ number_of_seasons: 1 }); mockGetTvDetails.mockReset().mockResolvedValue({ number_of_seasons: 1 });
}); });
function makePlexForm(payload: Record<string, unknown>): FormData {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
return form;
}
// ─── toOptionalInt ────────────────────────────────────────────────── // ─── toOptionalInt ──────────────────────────────────────────────────
describe("toOptionalInt", () => { describe("toOptionalInt", () => {
@@ -86,12 +92,6 @@ describe("toOptionalInt", () => {
// ─── parsePlexPayload ─────────────────────────────────────────────── // ─── parsePlexPayload ───────────────────────────────────────────────
describe("parsePlexPayload", () => { describe("parsePlexPayload", () => {
function makePlexForm(payload: Record<string, unknown>): FormData {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
return form;
}
test("parses a movie scrobble event", () => { test("parses a movie scrobble event", () => {
const result = parsePlexPayload( const result = parsePlexPayload(
makePlexForm({ 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", () => { test("returns null for non-scrobble events", () => {
expect(parsePlexPayload(makePlexForm({ event: "media.play" }))).toBeNull(); expect(parsePlexPayload(makePlexForm({ event: "media.play" }))).toBeNull();
}); });
@@ -451,6 +478,38 @@ describe("processWebhook", () => {
expect(result.status).toBe("success"); 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 () => { test("returns error when episode cannot be resolved", async () => {
mockResolveShowTmdbId.mockResolvedValue(null); mockResolveShowTmdbId.mockResolvedValue(null);