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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user