Add Emby webhook integration and remove unused username field

Add Emby as a third media server integration alongside Plex and Jellyfin.
Emby webhooks use a similar payload format (JSON with nested Item object
and ProviderIds). Also removes the mediaServerUsername field from all
webhook connections since it was never used for authentication — the
token-in-URL is the sole auth mechanism. This simplifies the connection
UX to a single "Connect" button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.6 committed 2026-03-05 00:50:32 -05:00
1 parent 0409a65a56
commit bafa45ec79
11 files changed
+2292 -178

No files matched your search

@@ -19,6 +19,25 @@ export function PlexIcon(props: SVGProps<SVGSVGElement>) {
); );
} }
export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M11.041 0L0 17.98l11.041 6.02 12.959-6.02zm.058 6.905l7.348 10.607-7.348 3.986-7.306-3.986z"
/>
</svg>
);
}
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) { export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
@@ -21,31 +21,23 @@ export function IntegrationsSection({
const plexConnection = connections.find((c) => c.provider === "plex") ?? null; const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
const jellyfinConnection = const jellyfinConnection =
connections.find((c) => c.provider === "jellyfin") ?? null; connections.find((c) => c.provider === "jellyfin") ?? null;
const embyConnection = connections.find((c) => c.provider === "emby") ?? null;
async function handleSave(provider: "plex" | "jellyfin", username: string) { async function handleConnect(provider: "plex" | "jellyfin" | "emby") {
const label = provider === "plex" ? "Plex" : "Jellyfin"; const label =
const isNew = !connections.find((c) => c.provider === provider); provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try { try {
const result = await saveWebhookConnection(provider, username); const result = await saveWebhookConnection(provider);
setConnections((prev) => { setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
const existing = prev.find((c) => c.provider === provider); toast.success(`${label} connected`);
if (existing) {
return prev.map((c) =>
c.provider === provider
? { ...result, recentEvents: existing.recentEvents }
: c,
);
}
return [...prev, { ...result, recentEvents: [] }];
});
toast.success(isNew ? `${label} connected` : `${label} updated`);
} catch { } catch {
toast.error(`Failed to save ${label} connection`); toast.error(`Failed to connect ${label}`);
} }
} }
async function handleDelete(provider: "plex" | "jellyfin") { async function handleDelete(provider: "plex" | "jellyfin" | "emby") {
const label = provider === "plex" ? "Plex" : "Jellyfin"; const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections; const previous = connections;
setConnections((prev) => prev.filter((c) => c.provider !== provider)); setConnections((prev) => prev.filter((c) => c.provider !== provider));
try { try {
@@ -57,8 +49,9 @@ export function IntegrationsSection({
} }
} }
async function handleRegenerateToken(provider: "plex" | "jellyfin") { async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") {
const label = provider === "plex" ? "Plex" : "Jellyfin"; const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try { try {
const result = await regenerateWebhookToken(provider); const result = await regenerateWebhookToken(provider);
setConnections((prev) => setConnections((prev) =>
@@ -72,19 +65,18 @@ export function IntegrationsSection({
} }
} }
async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) { async function handleToggle(
const label = provider === "plex" ? "Plex" : "Jellyfin"; provider: "plex" | "jellyfin" | "emby",
enabled: boolean,
) {
const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections; const previous = connections;
setConnections((prev) => setConnections((prev) =>
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)), prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
); );
try { try {
const conn = connections.find((c) => c.provider === provider); await saveWebhookConnection(provider, enabled);
await saveWebhookConnection(
provider,
conn?.mediaServerUsername ?? "",
enabled,
);
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`); toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
} catch { } catch {
setConnections(previous); setConnections(previous);
@@ -104,7 +96,7 @@ export function IntegrationsSection({
<WebhookCard <WebhookCard
provider="plex" provider="plex"
connection={plexConnection} connection={plexConnection}
onSave={handleSave} onConnect={handleConnect}
onDelete={handleDelete} onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken} onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle} onToggle={handleToggle}
@@ -112,7 +104,15 @@ export function IntegrationsSection({
<WebhookCard <WebhookCard
provider="jellyfin" provider="jellyfin"
connection={jellyfinConnection} connection={jellyfinConnection}
onSave={handleSave} onConnect={handleConnect}
onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle}
/>
<WebhookCard
provider="emby"
connection={embyConnection}
onConnect={handleConnect}
onDelete={handleDelete} onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken} onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle} onToggle={handleToggle}
+128 -123
View File
@@ -31,13 +31,12 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { JellyfinIcon, PlexIcon } from "./icons"; import { EmbyIcon, JellyfinIcon, PlexIcon } from "./icons";
export interface WebhookConnection { export interface WebhookConnection {
id: string; id: string;
provider: "plex" | "jellyfin"; provider: "plex" | "jellyfin" | "emby";
token: string; token: string;
mediaServerUsername: string;
enabled: boolean; enabled: boolean;
lastEventAt: string | null; lastEventAt: string | null;
recentEvents: { recentEvents: {
@@ -53,41 +52,41 @@ export interface WebhookConnection {
export function WebhookCard({ export function WebhookCard({
provider, provider,
connection, connection,
onSave, onConnect,
onDelete, onDelete,
onRegenerateToken, onRegenerateToken,
onToggle, onToggle,
}: { }: {
provider: "plex" | "jellyfin"; provider: "plex" | "jellyfin" | "emby";
connection: WebhookConnection | null; connection: WebhookConnection | null;
onSave: (provider: "plex" | "jellyfin", username: string) => Promise<void>; onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onDelete: (provider: "plex" | "jellyfin") => Promise<void>; onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onRegenerateToken: (provider: "plex" | "jellyfin") => Promise<void>; onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise<void>; onToggle: (
provider: "plex" | "jellyfin" | "emby",
enabled: boolean,
) => Promise<void>;
}) { }) {
const [username, setUsername] = useState( const [connecting, setConnecting] = useState(false);
connection?.mediaServerUsername ?? "",
);
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const [cardOpen, setCardOpen] = useState(false); const [cardOpen, setCardOpen] = useState(false);
const isPlex = provider === "plex"; const isPlex = provider === "plex";
const label = isPlex ? "Plex" : "Jellyfin"; const isEmby = provider === "emby";
const Icon = isPlex ? PlexIcon : JellyfinIcon; const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin";
const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon;
const webhookUrl = connection const webhookUrl = connection
? `${window.location.origin}/api/webhooks/${connection.token}` ? `${window.location.origin}/api/webhooks/${connection.token}`
: null; : null;
async function handleSave() { async function handleConnect() {
if (!username.trim()) return; setConnecting(true);
setSaving(true);
try { try {
await onSave(provider, username.trim()); await onConnect(provider);
} finally { } finally {
setSaving(false); setConnecting(false);
} }
} }
@@ -147,116 +146,109 @@ export function WebhookCard({
href="https://www.plex.tv/plex-pass/" href="https://www.plex.tv/plex-pass/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-medium text-foreground inline-flex items-center gap-0.5 hover:underline underline-offset-2" className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
> >
<span>Plex Pass</span> <span>Plex Pass</span>
<IconExternalLink className="size-3 inline-block translate-y-[-1px]" /> <IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
</a>{" "} </a>{" "}
subscription. subscription.
</p> </p>
</div> </div>
)} )}
<div> {isEmby && (
<label <div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
htmlFor={`${provider}-username`} <IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
className="mb-1 block text-xs text-muted-foreground" <p className="text-xs leading-relaxed text-foreground/80">
> Emby webhooks require an active{" "}
{label} username <a
</label> href="https://emby.media/premiere.html"
<div className="flex gap-2"> target="_blank"
<Input rel="noopener noreferrer"
id={`${provider}-username`} className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
placeholder={`Your ${label} username`}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{!connection ? (
<Button
onClick={handleSave}
disabled={saving || !username.trim()}
> >
{saving ? "Saving..." : "Connect"} <span>Emby Premiere</span>
</Button> <IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
) : ( </a>{" "}
username.trim() !== connection.mediaServerUsername && ( subscription.
<Button </p>
onClick={handleSave}
disabled={saving || !username.trim()}
variant="outline"
>
Update
</Button>
)
)}
</div> </div>
</div> )}
<AnimatePresence> {!connection ? (
{webhookUrl && ( <Button
<motion.div onClick={handleConnect}
initial={{ opacity: 0, height: 0 }} disabled={connecting}
animate={{ opacity: 1, height: "auto" }} className="w-full"
exit={{ opacity: 0, height: 0 }} >
className="space-y-3 overflow-hidden" {connecting ? "Connecting..." : `Connect ${label}`}
> </Button>
<div> ) : (
<label <AnimatePresence>
htmlFor={`${provider}-webhook-url`} {webhookUrl && (
className="mb-1 block text-xs text-muted-foreground" <motion.div
> initial={{ opacity: 0, height: 0 }}
Webhook URL animate={{ opacity: 1, height: "auto" }}
</label> exit={{ opacity: 0, height: 0 }}
<div className="flex gap-2"> className="space-y-3 overflow-hidden"
<Input >
id={`${provider}-webhook-url`} <div>
readOnly <label
value={webhookUrl} htmlFor={`${provider}-webhook-url`}
className="font-mono text-[10px] text-muted-foreground" className="mb-1 block text-xs text-muted-foreground"
/> >
<Tooltip> Webhook URL
<TooltipTrigger </label>
render={ <div className="flex gap-2">
<Button <Input
variant="outline" id={`${provider}-webhook-url`}
size="icon" readOnly
onClick={handleCopy} value={webhookUrl}
/> className="font-mono text-[10px] text-muted-foreground"
} />
> <Tooltip>
{copied ? ( <TooltipTrigger
<IconCheck className="text-green-400" /> render={
) : ( <Button
<IconCopy /> variant="outline"
)} size="icon"
</TooltipTrigger> onClick={handleCopy}
<TooltipContent>Copy URL</TooltipContent> />
</Tooltip> }
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onRegenerateToken(provider)} onClick={() => onRegenerateToken(provider)}
> >
<IconRefresh /> <IconRefresh />
Regenerate URL Regenerate URL
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onDelete(provider)} onClick={() => onDelete(provider)}
> >
<IconTrash /> <IconTrash />
Disconnect Disconnect
</Button> </Button>
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
)}
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}> <Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"> <CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
@@ -286,9 +278,26 @@ export function WebhookCard({
Sofa will automatically log movies and episodes when you Sofa will automatically log movies and episodes when you
finish watching them finish watching them
</li> </li>
</ol>
) : isEmby ? (
<ol className="list-inside list-decimal space-y-1.5">
<li> <li>
Make sure the username above matches your Plex account Open Emby, go to{" "}
name <span className="font-medium text-foreground">
Settings &gt; Webhooks
</span>
</li>
<li>Add a new webhook and paste the URL above</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">
Playback Stop
</span>{" "}
event type
</li>
<li>
Sofa will automatically log movies and episodes when you
finish watching them
</li> </li>
</ol> </ol>
) : ( ) : (
@@ -320,10 +329,6 @@ export function WebhookCard({
</span>{" "} </span>{" "}
notification type notification type
</li> </li>
<li>
Make sure the username above matches your Jellyfin
username
</li>
</ol> </ol>
)} )}
</div> </div>
-1
View File
@@ -46,7 +46,6 @@ export default async function SettingsPage() {
id: conn.id, id: conn.id,
provider: conn.provider, provider: conn.provider,
token: conn.token, token: conn.token,
mediaServerUsername: conn.mediaServerUsername,
enabled: conn.enabled, enabled: conn.enabled,
lastEventAt: conn.lastEventAt?.toISOString() ?? null, lastEventAt: conn.lastEventAt?.toISOString() ?? null,
recentEvents: events.map((e) => ({ recentEvents: events.map((e) => ({
+4
View File
@@ -6,6 +6,7 @@ import { webhookConnections } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import type { WebhookEvent } from "@/lib/services/webhooks"; import type { WebhookEvent } from "@/lib/services/webhooks";
import { import {
parseEmbyPayload,
parseJellyfinPayload, parseJellyfinPayload,
parsePlexPayload, parsePlexPayload,
processWebhook, processWebhook,
@@ -36,6 +37,9 @@ export async function POST(
if (connection.provider === "plex") { if (connection.provider === "plex") {
const formData = await req.formData(); const formData = await req.formData();
event = parsePlexPayload(formData); event = parsePlexPayload(formData);
} else if (connection.provider === "emby") {
const body = await req.json();
event = parseEmbyPayload(body);
} else { } else {
const body = await req.json(); const body = await req.json();
event = parseJellyfinPayload(body); event = parseJellyfinPayload(body);
@@ -0,0 +1 @@
ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`;
File diff suppressed because it is too large. Load diff
+10 -12
View File
@@ -29,18 +29,14 @@ async function getAdminSession() {
// --- Webhook actions --- // --- Webhook actions ---
export async function saveWebhookConnection( export async function saveWebhookConnection(
provider: "plex" | "jellyfin", provider: "plex" | "jellyfin" | "emby",
mediaServerUsername: string,
enabled?: boolean, enabled?: boolean,
) { ) {
const session = await getSession(); const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) { if (!["plex", "jellyfin", "emby"].includes(provider)) {
throw new Error("Invalid provider"); throw new Error("Invalid provider");
} }
if (!mediaServerUsername?.trim()) {
throw new Error("Media server username is required");
}
const existing = db const existing = db
.select() .select()
@@ -57,7 +53,6 @@ export async function saveWebhookConnection(
const connection = db const connection = db
.update(webhookConnections) .update(webhookConnections)
.set({ .set({
mediaServerUsername: mediaServerUsername.trim(),
enabled: typeof enabled === "boolean" ? enabled : existing.enabled, enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
}) })
.where(eq(webhookConnections.id, existing.id)) .where(eq(webhookConnections.id, existing.id))
@@ -81,7 +76,6 @@ export async function saveWebhookConnection(
userId: session.user.id, userId: session.user.id,
provider, provider,
token, token,
mediaServerUsername: mediaServerUsername.trim(),
enabled: true, enabled: true,
createdAt: now, createdAt: now,
}) })
@@ -95,10 +89,12 @@ export async function saveWebhookConnection(
}; };
} }
export async function deleteWebhookConnection(provider: "plex" | "jellyfin") { export async function deleteWebhookConnection(
provider: "plex" | "jellyfin" | "emby",
) {
const session = await getSession(); const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) { if (!["plex", "jellyfin", "emby"].includes(provider)) {
throw new Error("Invalid provider"); throw new Error("Invalid provider");
} }
@@ -112,10 +108,12 @@ export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
.run(); .run();
} }
export async function regenerateWebhookToken(provider: "plex" | "jellyfin") { export async function regenerateWebhookToken(
provider: "plex" | "jellyfin" | "emby",
) {
const session = await getSession(); const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) { if (!["plex", "jellyfin", "emby"].includes(provider)) {
throw new Error("Invalid provider"); throw new Error("Invalid provider");
} }
+5 -4
View File
@@ -181,7 +181,7 @@ export const userMovieWatches = sqliteTable(
.references(() => titles.id, { onDelete: "cascade" }), .references(() => titles.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", { source: text("source", {
enum: ["manual", "import", "plex", "jellyfin"], enum: ["manual", "import", "plex", "jellyfin", "emby"],
}) })
.notNull() .notNull()
.default("manual"), .default("manual"),
@@ -207,7 +207,7 @@ export const userEpisodeWatches = sqliteTable(
.references(() => episodes.id, { onDelete: "cascade" }), .references(() => episodes.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", { source: text("source", {
enum: ["manual", "import", "plex", "jellyfin"], enum: ["manual", "import", "plex", "jellyfin", "emby"],
}) })
.notNull() .notNull()
.default("manual"), .default("manual"),
@@ -297,9 +297,10 @@ export const webhookConnections = sqliteTable(
userId: text("userId") userId: text("userId")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
provider: text("provider", { enum: ["plex", "jellyfin"] }).notNull(), provider: text("provider", {
enum: ["plex", "jellyfin", "emby"],
}).notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
mediaServerUsername: text("mediaServerUsername").notNull(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true), enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(), createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
lastEventAt: int("lastEventAt", { mode: "timestamp" }), lastEventAt: int("lastEventAt", { mode: "timestamp" }),
+4 -4
View File
@@ -15,7 +15,7 @@ export function setTitleStatus(
titleId: string, titleId: string,
status: "watchlist" | "in_progress" | "completed", status: "watchlist" | "in_progress" | "completed",
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
source: "manual" | "import" | "plex" | "jellyfin" = "manual", source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) { ) {
const now = new Date(); const now = new Date();
db.insert(userTitleStatus) db.insert(userTitleStatus)
@@ -41,7 +41,7 @@ export function removeTitleStatus(userId: string, titleId: string) {
export function logMovieWatch( export function logMovieWatch(
userId: string, userId: string,
titleId: string, titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual", source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) { ) {
const now = new Date(); const now = new Date();
db.insert(userMovieWatches) db.insert(userMovieWatches)
@@ -70,7 +70,7 @@ export function logMovieWatch(
export function logEpisodeWatch( export function logEpisodeWatch(
userId: string, userId: string,
episodeId: string, episodeId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual", source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) { ) {
const now = new Date(); const now = new Date();
db.insert(userEpisodeWatches) db.insert(userEpisodeWatches)
@@ -111,7 +111,7 @@ export function logEpisodeWatch(
export function markAllEpisodesWatched( export function markAllEpisodesWatched(
userId: string, userId: string,
titleId: string, titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual", source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) { ) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return; if (!title || title.type !== "tv") return;
+41 -3
View File
@@ -15,7 +15,7 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking";
// ─── Types ────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────
export interface WebhookEvent { export interface WebhookEvent {
provider: "plex" | "jellyfin"; provider: "plex" | "jellyfin" | "emby";
mediaType: "movie" | "episode"; mediaType: "movie" | "episode";
title: string; title: string;
tmdbId?: number; tmdbId?: number;
@@ -109,6 +109,39 @@ export function parseJellyfinPayload(
}; };
} }
export function parseEmbyPayload(
body: Record<string, unknown>,
): WebhookEvent | null {
// 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;
const item = body.Item as Record<string, unknown> | undefined;
if (!item) return null;
const itemType = item.Type as string | undefined;
const isMovie = itemType === "Movie";
const isEpisode = itemType === "Episode";
if (!isMovie && !isEpisode) return null;
const providerIds = (item.ProviderIds ?? {}) as Record<string, string>;
const tmdbRaw = providerIds.Tmdb;
const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined;
return {
provider: "emby",
mediaType: isMovie ? "movie" : "episode",
title: (item.Name ?? "") as string,
tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined,
imdbId: providerIds.Imdb || undefined,
tvdbId: providerIds.Tvdb || undefined,
seasonNumber: item.ParentIndexNumber as number | undefined,
episodeNumber: item.IndexNumber as number | undefined,
showTitle: (item.SeriesName ?? item.ShowName) as string | undefined,
};
}
// ─── Title Resolution ─────────────────────────────────────────────── // ─── Title Resolution ───────────────────────────────────────────────
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> { async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
@@ -240,7 +273,12 @@ function logEvent(
db.insert(webhookEventLog) db.insert(webhookEventLog)
.values({ .values({
connectionId, connectionId,
eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop", eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null, mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null, mediaTitle: event?.title ?? null,
status, status,
@@ -260,7 +298,7 @@ function logEvent(
export async function processWebhook( export async function processWebhook(
connectionId: string, connectionId: string,
userId: string, userId: string,
provider: "plex" | "jellyfin", provider: "plex" | "jellyfin" | "emby",
event: WebhookEvent, event: WebhookEvent,
): Promise<{ status: "success" | "ignored" | "error"; message: string }> { ): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
try { try {