mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ beforeEach(() => {
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
describe("toOptionalInt", () => {
|
||||
@@ -86,12 +92,6 @@ describe("toOptionalInt", () => {
|
||||
// ─── 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", () => {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user