mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
+10
-12
@@ -29,18 +29,14 @@ async function getAdminSession() {
|
||||
// --- Webhook actions ---
|
||||
|
||||
export async function saveWebhookConnection(
|
||||
provider: "plex" | "jellyfin",
|
||||
mediaServerUsername: string,
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
enabled?: boolean,
|
||||
) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!["plex", "jellyfin"].includes(provider)) {
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
if (!mediaServerUsername?.trim()) {
|
||||
throw new Error("Media server username is required");
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
@@ -57,7 +53,6 @@ export async function saveWebhookConnection(
|
||||
const connection = db
|
||||
.update(webhookConnections)
|
||||
.set({
|
||||
mediaServerUsername: mediaServerUsername.trim(),
|
||||
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
||||
})
|
||||
.where(eq(webhookConnections.id, existing.id))
|
||||
@@ -81,7 +76,6 @@ export async function saveWebhookConnection(
|
||||
userId: session.user.id,
|
||||
provider,
|
||||
token,
|
||||
mediaServerUsername: mediaServerUsername.trim(),
|
||||
enabled: true,
|
||||
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();
|
||||
|
||||
if (!["plex", "jellyfin"].includes(provider)) {
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
|
||||
@@ -112,10 +108,12 @@ export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function regenerateWebhookToken(provider: "plex" | "jellyfin") {
|
||||
export async function regenerateWebhookToken(
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!["plex", "jellyfin"].includes(provider)) {
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -181,7 +181,7 @@ export const userMovieWatches = sqliteTable(
|
||||
.references(() => titles.id, { onDelete: "cascade" }),
|
||||
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
||||
source: text("source", {
|
||||
enum: ["manual", "import", "plex", "jellyfin"],
|
||||
enum: ["manual", "import", "plex", "jellyfin", "emby"],
|
||||
})
|
||||
.notNull()
|
||||
.default("manual"),
|
||||
@@ -207,7 +207,7 @@ export const userEpisodeWatches = sqliteTable(
|
||||
.references(() => episodes.id, { onDelete: "cascade" }),
|
||||
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
||||
source: text("source", {
|
||||
enum: ["manual", "import", "plex", "jellyfin"],
|
||||
enum: ["manual", "import", "plex", "jellyfin", "emby"],
|
||||
})
|
||||
.notNull()
|
||||
.default("manual"),
|
||||
@@ -297,9 +297,10 @@ export const webhookConnections = sqliteTable(
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.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(),
|
||||
mediaServerUsername: text("mediaServerUsername").notNull(),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
|
||||
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
|
||||
|
||||
@@ -15,7 +15,7 @@ export function setTitleStatus(
|
||||
titleId: string,
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
// 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();
|
||||
db.insert(userTitleStatus)
|
||||
@@ -41,7 +41,7 @@ export function removeTitleStatus(userId: string, titleId: string) {
|
||||
export function logMovieWatch(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(userMovieWatches)
|
||||
@@ -70,7 +70,7 @@ export function logMovieWatch(
|
||||
export function logEpisodeWatch(
|
||||
userId: string,
|
||||
episodeId: string,
|
||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(userEpisodeWatches)
|
||||
@@ -111,7 +111,7 @@ export function logEpisodeWatch(
|
||||
export function markAllEpisodesWatched(
|
||||
userId: 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();
|
||||
if (!title || title.type !== "tv") return;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WebhookEvent {
|
||||
provider: "plex" | "jellyfin";
|
||||
provider: "plex" | "jellyfin" | "emby";
|
||||
mediaType: "movie" | "episode";
|
||||
title: string;
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
|
||||
@@ -240,7 +273,12 @@ function logEvent(
|
||||
db.insert(webhookEventLog)
|
||||
.values({
|
||||
connectionId,
|
||||
eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop",
|
||||
eventType:
|
||||
event?.provider === "plex"
|
||||
? "media.scrobble"
|
||||
: event?.provider === "emby"
|
||||
? "playback.stop"
|
||||
: "PlaybackStop",
|
||||
mediaType: event?.mediaType ?? null,
|
||||
mediaTitle: event?.title ?? null,
|
||||
status,
|
||||
@@ -260,7 +298,7 @@ function logEvent(
|
||||
export async function processWebhook(
|
||||
connectionId: string,
|
||||
userId: string,
|
||||
provider: "plex" | "jellyfin",
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
event: WebhookEvent,
|
||||
): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user