mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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
581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import {
|
|
clearAllTables,
|
|
insertIntegration,
|
|
insertMovieWatch,
|
|
insertTvShow,
|
|
insertUser,
|
|
} from "@sofa/test/db";
|
|
|
|
import {
|
|
parseEmbyPayload,
|
|
parseJellyfinPayload,
|
|
parsePlexPayload,
|
|
processWebhook,
|
|
toOptionalInt,
|
|
type WebhookEvent,
|
|
} from "../src/webhooks";
|
|
|
|
const { mockResolveMovieTmdbId, mockResolveShowTmdbId } = vi.hoisted(() => ({
|
|
mockResolveMovieTmdbId: vi.fn(async () => null as number | null),
|
|
mockResolveShowTmdbId: vi.fn(async () => null as number | null),
|
|
}));
|
|
|
|
vi.mock("../src/imports/resolve", () => ({
|
|
resolveMovieTmdbId: mockResolveMovieTmdbId,
|
|
resolveShowTmdbId: mockResolveShowTmdbId,
|
|
}));
|
|
|
|
const { mockGetOrFetchTitleByTmdbId, mockRefreshTvChildren } = vi.hoisted(() => ({
|
|
mockGetOrFetchTitleByTmdbId: vi.fn(async () => null as { id: string } | null),
|
|
mockRefreshTvChildren: vi.fn(async () => {}),
|
|
}));
|
|
|
|
vi.mock("../src/metadata", () => ({
|
|
getOrFetchTitleByTmdbId: mockGetOrFetchTitleByTmdbId,
|
|
refreshTvChildren: mockRefreshTvChildren,
|
|
}));
|
|
|
|
const { mockGetTvDetails } = vi.hoisted(() => ({
|
|
mockGetTvDetails: vi.fn(async () => ({ number_of_seasons: 1 })),
|
|
}));
|
|
|
|
vi.mock("@sofa/tmdb/client", () => ({
|
|
getTvDetails: mockGetTvDetails,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
clearAllTables();
|
|
mockResolveMovieTmdbId.mockReset().mockResolvedValue(null);
|
|
mockResolveShowTmdbId.mockReset().mockResolvedValue(null);
|
|
mockGetOrFetchTitleByTmdbId.mockReset().mockResolvedValue(null);
|
|
mockRefreshTvChildren.mockReset();
|
|
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", () => {
|
|
test("returns integer from number", () => {
|
|
expect(toOptionalInt(42)).toBe(42);
|
|
});
|
|
|
|
test("returns undefined for float", () => {
|
|
expect(toOptionalInt(3.14)).toBeUndefined();
|
|
});
|
|
|
|
test("parses integer from string", () => {
|
|
expect(toOptionalInt("123")).toBe(123);
|
|
});
|
|
|
|
test("returns undefined for non-numeric string", () => {
|
|
expect(toOptionalInt("abc")).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined for empty string", () => {
|
|
expect(toOptionalInt("")).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined for null/undefined", () => {
|
|
expect(toOptionalInt(null)).toBeUndefined();
|
|
expect(toOptionalInt(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── parsePlexPayload ───────────────────────────────────────────────
|
|
|
|
describe("parsePlexPayload", () => {
|
|
test("parses a movie scrobble event", () => {
|
|
const result = parsePlexPayload(
|
|
makePlexForm({
|
|
event: "media.scrobble",
|
|
Metadata: {
|
|
type: "movie",
|
|
title: "Inception",
|
|
Guid: [{ id: "tmdb://27205" }, { id: "imdb://tt1375666" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(result).toEqual({
|
|
provider: "plex",
|
|
mediaType: "movie",
|
|
title: "Inception",
|
|
tmdbId: 27205,
|
|
imdbId: "tt1375666",
|
|
tvdbId: undefined,
|
|
seasonNumber: undefined,
|
|
episodeNumber: undefined,
|
|
showTitle: undefined,
|
|
});
|
|
});
|
|
|
|
test("parses an episode scrobble event", () => {
|
|
const result = parsePlexPayload(
|
|
makePlexForm({
|
|
event: "media.scrobble",
|
|
Metadata: {
|
|
type: "episode",
|
|
title: "Pilot",
|
|
grandparentTitle: "Breaking Bad",
|
|
parentIndex: 1,
|
|
index: 1,
|
|
Guid: [{ id: "tvdb://349232" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(result).toEqual({
|
|
provider: "plex",
|
|
mediaType: "episode",
|
|
title: "Pilot",
|
|
tmdbId: undefined,
|
|
imdbId: undefined,
|
|
tvdbId: "349232",
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
showTitle: "Breaking Bad",
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
test("returns null for missing payload", () => {
|
|
expect(parsePlexPayload(new FormData())).toBeNull();
|
|
});
|
|
|
|
test("returns null for invalid JSON", () => {
|
|
const form = new FormData();
|
|
form.set("payload", "not json");
|
|
expect(parsePlexPayload(form)).toBeNull();
|
|
});
|
|
|
|
test("returns null for unsupported media type", () => {
|
|
const result = parsePlexPayload(
|
|
makePlexForm({
|
|
event: "media.scrobble",
|
|
Metadata: { type: "track", title: "Some Song" },
|
|
}),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns null when Metadata is missing", () => {
|
|
expect(parsePlexPayload(makePlexForm({ event: "media.scrobble" }))).toBeNull();
|
|
});
|
|
|
|
test("handles lowercase guid key", () => {
|
|
const result = parsePlexPayload(
|
|
makePlexForm({
|
|
event: "media.scrobble",
|
|
Metadata: {
|
|
type: "movie",
|
|
title: "Test",
|
|
guid: [{ id: "tmdb://999" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(result?.tmdbId).toBe(999);
|
|
});
|
|
});
|
|
|
|
// ─── parseJellyfinPayload ───────────────────────────────────────────
|
|
|
|
describe("parseJellyfinPayload", () => {
|
|
test("parses a completed movie playback", () => {
|
|
const result = parseJellyfinPayload({
|
|
NotificationType: "PlaybackStop",
|
|
PlayedToCompletion: true,
|
|
ItemType: "Movie",
|
|
Name: "The Matrix",
|
|
Provider_tmdb: "603",
|
|
Provider_imdb: "tt0133093",
|
|
});
|
|
expect(result).toEqual({
|
|
provider: "jellyfin",
|
|
mediaType: "movie",
|
|
title: "The Matrix",
|
|
tmdbId: 603,
|
|
imdbId: "tt0133093",
|
|
tvdbId: undefined,
|
|
seasonNumber: undefined,
|
|
episodeNumber: undefined,
|
|
showTitle: undefined,
|
|
});
|
|
});
|
|
|
|
test("parses a completed episode playback", () => {
|
|
const result = parseJellyfinPayload({
|
|
NotificationType: "PlaybackStop",
|
|
PlayedToCompletion: true,
|
|
ItemType: "Episode",
|
|
Name: "Ozymandias",
|
|
SeriesName: "Breaking Bad",
|
|
SeasonNumber: 5,
|
|
EpisodeNumber: 14,
|
|
Provider_tvdb: "4882562",
|
|
});
|
|
expect(result).toEqual({
|
|
provider: "jellyfin",
|
|
mediaType: "episode",
|
|
title: "Ozymandias",
|
|
tmdbId: undefined,
|
|
imdbId: undefined,
|
|
tvdbId: "4882562",
|
|
seasonNumber: 5,
|
|
episodeNumber: 14,
|
|
showTitle: "Breaking Bad",
|
|
});
|
|
});
|
|
|
|
test("returns null when not PlaybackStop", () => {
|
|
expect(
|
|
parseJellyfinPayload({
|
|
NotificationType: "PlaybackStart",
|
|
PlayedToCompletion: true,
|
|
ItemType: "Movie",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("returns null when not played to completion", () => {
|
|
expect(
|
|
parseJellyfinPayload({
|
|
NotificationType: "PlaybackStop",
|
|
PlayedToCompletion: false,
|
|
ItemType: "Movie",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("returns null for unsupported item type", () => {
|
|
expect(
|
|
parseJellyfinPayload({
|
|
NotificationType: "PlaybackStop",
|
|
PlayedToCompletion: true,
|
|
ItemType: "Audio",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── parseEmbyPayload ───────────────────────────────────────────────
|
|
|
|
describe("parseEmbyPayload", () => {
|
|
test("parses playback.stop movie event", () => {
|
|
const result = parseEmbyPayload({
|
|
Event: "playback.stop",
|
|
PlayedToCompletion: true,
|
|
Item: {
|
|
Type: "Movie",
|
|
Name: "Interstellar",
|
|
ProviderIds: { Tmdb: "157336", Imdb: "tt0816692" },
|
|
},
|
|
});
|
|
expect(result).toEqual({
|
|
provider: "emby",
|
|
mediaType: "movie",
|
|
title: "Interstellar",
|
|
tmdbId: 157336,
|
|
imdbId: "tt0816692",
|
|
tvdbId: undefined,
|
|
seasonNumber: undefined,
|
|
episodeNumber: undefined,
|
|
showTitle: undefined,
|
|
});
|
|
});
|
|
|
|
test("parses PlaybackStop event variant", () => {
|
|
const result = parseEmbyPayload({
|
|
Event: "PlaybackStop",
|
|
PlayedToCompletion: true,
|
|
Item: { Type: "Movie", Name: "Test", ProviderIds: {} },
|
|
});
|
|
expect(result?.provider).toBe("emby");
|
|
});
|
|
|
|
test("parses episode event", () => {
|
|
const result = parseEmbyPayload({
|
|
Event: "playback.stop",
|
|
PlayedToCompletion: true,
|
|
Item: {
|
|
Type: "Episode",
|
|
Name: "Pilot",
|
|
SeriesName: "Lost",
|
|
ParentIndexNumber: 1,
|
|
IndexNumber: 1,
|
|
ProviderIds: { Tvdb: "127131" },
|
|
},
|
|
});
|
|
expect(result).toEqual({
|
|
provider: "emby",
|
|
mediaType: "episode",
|
|
title: "Pilot",
|
|
tmdbId: undefined,
|
|
imdbId: undefined,
|
|
tvdbId: "127131",
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
showTitle: "Lost",
|
|
});
|
|
});
|
|
|
|
test("returns null for non-stop events", () => {
|
|
expect(
|
|
parseEmbyPayload({
|
|
Event: "playback.start",
|
|
PlayedToCompletion: true,
|
|
Item: { Type: "Movie", Name: "X" },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("returns null when not played to completion", () => {
|
|
expect(
|
|
parseEmbyPayload({
|
|
Event: "playback.stop",
|
|
PlayedToCompletion: false,
|
|
Item: { Type: "Movie", Name: "X" },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("returns null when Item is missing", () => {
|
|
expect(parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: true })).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── processWebhook ─────────────────────────────────────────────────
|
|
|
|
describe("processWebhook", () => {
|
|
const userId = "user-1";
|
|
let connectionId: string;
|
|
|
|
beforeEach(() => {
|
|
insertUser(userId);
|
|
const integration = insertIntegration(userId, "plex", "plex-token");
|
|
connectionId = integration.id;
|
|
});
|
|
|
|
test("logs a movie watch on successful resolution", async () => {
|
|
const { insertTitle } = await import("@sofa/test/db");
|
|
insertTitle({ id: "title-1", tmdbId: 27205, type: "movie", title: "Inception" });
|
|
mockResolveMovieTmdbId.mockResolvedValue(27205);
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "title-1" });
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "movie",
|
|
title: "Inception",
|
|
tmdbId: 27205,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("success");
|
|
});
|
|
|
|
test("returns error when movie TMDB ID cannot be resolved", async () => {
|
|
mockResolveMovieTmdbId.mockResolvedValue(null);
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "movie",
|
|
title: "Unknown Movie",
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("error");
|
|
expect(result.message).toContain("Could not resolve TMDB ID");
|
|
});
|
|
|
|
test("returns error when title import fails", async () => {
|
|
mockResolveMovieTmdbId.mockResolvedValue(999);
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue(null);
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "jellyfin",
|
|
mediaType: "movie",
|
|
title: "Bad Movie",
|
|
tmdbId: 999,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "jellyfin", event);
|
|
expect(result.status).toBe("error");
|
|
expect(result.message).toContain("Failed to import movie");
|
|
});
|
|
|
|
test("deduplicates movie watches within 5 minutes", async () => {
|
|
mockResolveMovieTmdbId.mockResolvedValue(27205);
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "title-1" });
|
|
|
|
// Seed a recent watch
|
|
const { insertTitle } = await import("@sofa/test/db");
|
|
insertTitle({ id: "title-1", tmdbId: 27205, type: "movie", title: "Inception" });
|
|
insertMovieWatch(userId, "title-1");
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "movie",
|
|
title: "Inception",
|
|
tmdbId: 27205,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("ignored");
|
|
expect(result.message).toContain("Duplicate");
|
|
});
|
|
|
|
test("logs an episode watch on successful resolution", async () => {
|
|
mockResolveShowTmdbId.mockResolvedValue(1396);
|
|
const { titleId } = insertTvShow("tv-1", 1396, 1, 1, { title: "Breaking Bad" });
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: titleId });
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "jellyfin",
|
|
mediaType: "episode",
|
|
title: "Pilot",
|
|
showTitle: "Breaking Bad",
|
|
tmdbId: 1396,
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "jellyfin", event);
|
|
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);
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "emby",
|
|
mediaType: "episode",
|
|
title: "Some Episode",
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "emby", event);
|
|
expect(result.status).toBe("error");
|
|
expect(result.message).toContain("Could not resolve episode");
|
|
});
|
|
|
|
test("returns error when season is not found", async () => {
|
|
mockResolveShowTmdbId.mockResolvedValue(1396);
|
|
insertTvShow("tv-1", 1396, 1, 3, { title: "Breaking Bad" });
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
|
|
mockGetTvDetails.mockResolvedValue({ number_of_seasons: 1 });
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "episode",
|
|
title: "Episode",
|
|
showTitle: "Breaking Bad",
|
|
tmdbId: 1396,
|
|
seasonNumber: 99,
|
|
episodeNumber: 1,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("error");
|
|
expect(result.message).toContain("Season 99 not found");
|
|
});
|
|
|
|
test("returns error when episode number is not found in season", async () => {
|
|
mockResolveShowTmdbId.mockResolvedValue(1396);
|
|
insertTvShow("tv-1", 1396, 1, 3, { title: "Breaking Bad" });
|
|
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
|
|
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "episode",
|
|
title: "Episode",
|
|
showTitle: "Breaking Bad",
|
|
tmdbId: 1396,
|
|
seasonNumber: 1,
|
|
episodeNumber: 99,
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("error");
|
|
expect(result.message).toContain("S1E99 not found");
|
|
});
|
|
|
|
test("returns error when episode is missing season/episode numbers", async () => {
|
|
const event: WebhookEvent = {
|
|
provider: "plex",
|
|
mediaType: "episode",
|
|
title: "Mystery Episode",
|
|
};
|
|
|
|
const result = await processWebhook(connectionId, userId, "plex", event);
|
|
expect(result.status).toBe("error");
|
|
});
|
|
});
|