mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
2 files changed
+146
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user