fix(webhooks): accept native Emby payloads, ignore episode TMDB ids, refresh on missing episodes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 16:46:00 -04:00
1 parent f05e63bab7
commit 5a96fd39f1
2 files changed
+146 -7

No files matched your search

+28 -7
View File
@@ -109,7 +109,9 @@ export function parseJellyfinPayload(body: Record<string, unknown>): WebhookEven
const isEpisode = itemType === "Episode";
if (!isMovie && !isEpisode) return null;
const tmdbId = toOptionalInt(body.Provider_tmdb);
// Item-level TMDB ids on episode events identify the episode, not the series
// (same as Plex, see #53) — rely on IMDb/TVDB/title to resolve the show.
const tmdbId = isMovie ? toOptionalInt(body.Provider_tmdb) : undefined;
return {
provider: "jellyfin",
@@ -128,7 +130,13 @@ export function parseEmbyPayload(body: Record<string, unknown>): WebhookEvent |
// Emby sends "playback.stop" or "PlaybackStop" depending on webhook plugin version
const event = body.Event as string | undefined;
if (event !== "playback.stop" && event !== "PlaybackStop") return null;
if (body.PlayedToCompletion !== true) return null;
// Native Emby webhooks (4.7.9+) nest the flag under PlaybackInfo; older plugin
// payloads put it at the top level. Accept either.
const playbackInfo = body.PlaybackInfo as Record<string, unknown> | undefined;
const playedToCompletion =
body.PlayedToCompletion === true || playbackInfo?.PlayedToCompletion === true;
if (!playedToCompletion) return null;
const item = body.Item as Record<string, unknown> | undefined;
if (!item) return null;
@@ -139,7 +147,9 @@ export function parseEmbyPayload(body: Record<string, unknown>): WebhookEvent |
if (!isMovie && !isEpisode) return null;
const providerIds = (item.ProviderIds ?? {}) as Record<string, string>;
const tmdbId = toOptionalInt(providerIds.Tmdb);
// Item-level TMDB ids on episode events identify the episode, not the series
// (same as Plex, see #53) — rely on IMDb/TVDB/title to resolve the show.
const tmdbId = isMovie ? toOptionalInt(providerIds.Tmdb) : undefined;
return {
provider: "emby",
@@ -277,12 +287,25 @@ export async function processWebhook(
// Find the episode in our DB
let season = findSeasonByTitleAndNumber(title.id, resolved.seasonNumber);
if (!season) {
// Season might be newly released — try refreshing from TMDB
// Sofa doesn't store specials (season 0). Don't refetch the whole show for them.
if (!season && resolved.seasonNumber === 0) {
logEvent(connectionId, event, "ignored", "Specials (season 0) are not tracked");
return { status: "ignored", message: "Specials are not tracked" };
}
let episode = season
? findEpisodeBySeasonAndNumber(season.id, resolved.episodeNumber)
: undefined;
if (!episode) {
// Season or episode may be newer than our last TMDB fetch — refresh once.
try {
const show = await getTvDetails(resolved.showTmdbId);
await refreshTvChildren(title.id, resolved.showTmdbId, show.number_of_seasons);
season = findSeasonByTitleAndNumber(title.id, resolved.seasonNumber);
episode = season
? findEpisodeBySeasonAndNumber(season.id, resolved.episodeNumber)
: undefined;
} catch (err) {
log.warn(`Failed to refresh seasons for TMDB ${resolved.showTmdbId}:`, err);
}
@@ -296,8 +319,6 @@ export async function processWebhook(
};
}
const episode = findEpisodeBySeasonAndNumber(season.id, resolved.episodeNumber);
if (!episode) {
logEvent(
connectionId,
+118
View File
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { episodes } from "@sofa/db/schema";
import {
clearAllTables,
insertIntegration,
insertMovieWatch,
insertTvShow,
insertUser,
testDb,
} from "@sofa/test/db";
import {
@@ -264,6 +266,22 @@ describe("parseJellyfinPayload", () => {
});
});
test("ignores the item TMDB id for episodes", () => {
const result = parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Episode",
Name: "Ozymandias",
SeriesName: "Breaking Bad",
SeasonNumber: 5,
EpisodeNumber: 14,
Provider_tmdb: "62085",
Provider_tvdb: "4882562",
});
expect(result?.tmdbId).toBeUndefined();
expect(result?.tvdbId).toBe("4882562");
});
test("returns null when not PlaybackStop", () => {
expect(
parseJellyfinPayload({
@@ -356,6 +374,61 @@ describe("parseEmbyPayload", () => {
});
});
test("ignores the item TMDB id for episodes", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: {
Type: "Episode",
Name: "Pilot",
SeriesName: "Lost",
ParentIndexNumber: 1,
IndexNumber: 1,
ProviderIds: { Tvdb: "127131", Tmdb: "62085" },
},
});
expect(result?.tmdbId).toBeUndefined();
expect(result?.tvdbId).toBe("127131");
});
test("parses a native Emby payload with PlaybackInfo.PlayedToCompletion", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
User: { Name: "u", Id: "1" },
Item: {
Type: "Episode",
Name: "Pilot",
SeriesName: "Lost",
ParentIndexNumber: 1,
IndexNumber: 1,
ProviderIds: { Tvdb: "127131" },
},
PlaybackInfo: { PlayedToCompletion: true, PositionTicks: 1 },
});
expect(result).not.toBeNull();
expect(result?.mediaType).toBe("episode");
expect(result?.tvdbId).toBe("127131");
expect(result?.seasonNumber).toBe(1);
});
test("ignores a native Emby payload that was not played to completion", () => {
expect(
parseEmbyPayload({
Event: "playback.stop",
User: { Name: "u", Id: "1" },
Item: {
Type: "Episode",
Name: "Pilot",
SeriesName: "Lost",
ParentIndexNumber: 1,
IndexNumber: 1,
ProviderIds: { Tvdb: "127131" },
},
PlaybackInfo: { PlayedToCompletion: false },
}),
).toBeNull();
});
test("returns null for non-stop events", () => {
expect(
parseEmbyPayload({
@@ -569,6 +642,51 @@ describe("processWebhook", () => {
expect(result.message).toContain("S1E99 not found");
});
test("refreshes when the season exists but the episode is missing", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
insertTvShow("tv-1", 1396, 1, 1, { title: "Breaking Bad" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
mockRefreshTvChildren.mockImplementation(async () => {
testDb
.insert(episodes)
.values({ id: "tv-1-s1e2", seasonId: "tv-1-s1", episodeNumber: 2, name: "S1E2" })
.run();
});
const event: WebhookEvent = {
provider: "plex",
mediaType: "episode",
title: "E2",
showTitle: "Breaking Bad",
seasonNumber: 1,
episodeNumber: 2,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("success");
expect(mockGetTvDetails).toHaveBeenCalledTimes(1);
});
test("ignores specials without refetching the show", async () => {
mockResolveShowTmdbId.mockResolvedValue(1396);
insertTvShow("tv-1", 1396, 1, 1, { title: "Breaking Bad" });
mockGetOrFetchTitleByTmdbId.mockResolvedValue({ id: "tv-1" });
const event: WebhookEvent = {
provider: "plex",
mediaType: "episode",
title: "Special",
showTitle: "Breaking Bad",
seasonNumber: 0,
episodeNumber: 1,
};
const result = await processWebhook(connectionId, userId, "plex", event);
expect(result.status).toBe("ignored");
expect(mockGetTvDetails).not.toHaveBeenCalled();
expect(mockRefreshTvChildren).not.toHaveBeenCalled();
});
test("returns error when episode is missing season/episode numbers", async () => {
const event: WebhookEvent = {
provider: "plex",