From bafa45ec79fc3004ad244127ae3a56eccf3be00f Mon Sep 17 00:00:00 2001
From: Jake Jarvis
Date: Thu, 5 Mar 2026 00:50:32 -0500
Subject: [PATCH] Add Emby webhook integration and remove unused username field
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
app/(pages)/settings/_components/icons.tsx | 19 +
.../_components/integrations-section.tsx | 62 +-
.../settings/_components/webhook-card.tsx | 251 +-
app/(pages)/settings/page.tsx | 1 -
app/api/webhooks/[token]/route.ts | 4 +
.../migration.sql | 1 +
.../snapshot.json | 2049 +++++++++++++++++
lib/actions/settings.ts | 22 +-
lib/db/schema.ts | 9 +-
lib/services/tracking.ts | 8 +-
lib/services/webhooks.ts | 44 +-
11 files changed, 2292 insertions(+), 178 deletions(-)
create mode 100644 drizzle/20260305054757_high_cobalt_man/migration.sql
create mode 100644 drizzle/20260305054757_high_cobalt_man/snapshot.json
diff --git a/app/(pages)/settings/_components/icons.tsx b/app/(pages)/settings/_components/icons.tsx
index e778f30..84724e9 100644
--- a/app/(pages)/settings/_components/icons.tsx
+++ b/app/(pages)/settings/_components/icons.tsx
@@ -19,6 +19,25 @@ export function PlexIcon(props: SVGProps) {
);
}
+export function EmbyIcon(props: SVGProps) {
+ return (
+
+ {/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
+
+
+ );
+}
+
export function JellyfinIcon(props: SVGProps) {
return (
c.provider === "plex") ?? null;
const jellyfinConnection =
connections.find((c) => c.provider === "jellyfin") ?? null;
+ const embyConnection = connections.find((c) => c.provider === "emby") ?? null;
- async function handleSave(provider: "plex" | "jellyfin", username: string) {
- const label = provider === "plex" ? "Plex" : "Jellyfin";
- const isNew = !connections.find((c) => c.provider === provider);
+ async function handleConnect(provider: "plex" | "jellyfin" | "emby") {
+ const label =
+ provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try {
- const result = await saveWebhookConnection(provider, username);
- setConnections((prev) => {
- const existing = prev.find((c) => c.provider === provider);
- 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`);
+ const result = await saveWebhookConnection(provider);
+ setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
+ toast.success(`${label} connected`);
} catch {
- toast.error(`Failed to save ${label} connection`);
+ toast.error(`Failed to connect ${label}`);
}
}
- async function handleDelete(provider: "plex" | "jellyfin") {
- const label = provider === "plex" ? "Plex" : "Jellyfin";
+ async function handleDelete(provider: "plex" | "jellyfin" | "emby") {
+ const label =
+ provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections;
setConnections((prev) => prev.filter((c) => c.provider !== provider));
try {
@@ -57,8 +49,9 @@ export function IntegrationsSection({
}
}
- async function handleRegenerateToken(provider: "plex" | "jellyfin") {
- const label = provider === "plex" ? "Plex" : "Jellyfin";
+ async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") {
+ const label =
+ provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try {
const result = await regenerateWebhookToken(provider);
setConnections((prev) =>
@@ -72,19 +65,18 @@ export function IntegrationsSection({
}
}
- async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) {
- const label = provider === "plex" ? "Plex" : "Jellyfin";
+ async function handleToggle(
+ provider: "plex" | "jellyfin" | "emby",
+ enabled: boolean,
+ ) {
+ const label =
+ provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections;
setConnections((prev) =>
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
);
try {
- const conn = connections.find((c) => c.provider === provider);
- await saveWebhookConnection(
- provider,
- conn?.mediaServerUsername ?? "",
- enabled,
- );
+ await saveWebhookConnection(provider, enabled);
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
} catch {
setConnections(previous);
@@ -104,7 +96,7 @@ export function IntegrationsSection({
+ Promise;
- onDelete: (provider: "plex" | "jellyfin") => Promise;
- onRegenerateToken: (provider: "plex" | "jellyfin") => Promise;
- onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise;
+ onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise;
+ onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise;
+ onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise;
+ onToggle: (
+ provider: "plex" | "jellyfin" | "emby",
+ enabled: boolean,
+ ) => Promise;
}) {
- const [username, setUsername] = useState(
- connection?.mediaServerUsername ?? "",
- );
- const [saving, setSaving] = useState(false);
+ const [connecting, setConnecting] = useState(false);
const [copied, setCopied] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [cardOpen, setCardOpen] = useState(false);
const isPlex = provider === "plex";
- const label = isPlex ? "Plex" : "Jellyfin";
- const Icon = isPlex ? PlexIcon : JellyfinIcon;
+ const isEmby = provider === "emby";
+ const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin";
+ const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon;
const webhookUrl = connection
? `${window.location.origin}/api/webhooks/${connection.token}`
: null;
- async function handleSave() {
- if (!username.trim()) return;
- setSaving(true);
+ async function handleConnect() {
+ setConnecting(true);
try {
- await onSave(provider, username.trim());
+ await onConnect(provider);
} finally {
- setSaving(false);
+ setConnecting(false);
}
}
@@ -147,116 +146,109 @@ export function WebhookCard({
href="https://www.plex.tv/plex-pass/"
target="_blank"
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"
>
Plex Pass
-
+
{" "}
subscription.
)}
-
-
- {label} username
-
-
-
setUsername(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSave()}
- />
- {!connection ? (
-
+
+
+ Emby webhooks require an active{" "}
+
- {saving ? "Saving..." : "Connect"}
-
- ) : (
- username.trim() !== connection.mediaServerUsername && (
-
- Update
-
- )
- )}
+
Emby Premiere
+
+ {" "}
+ subscription.
+
-
+ )}
-
- {webhookUrl && (
-
-
-
- Webhook URL
-
-
-
-
-
- }
- >
- {copied ? (
-
- ) : (
-
- )}
-
- Copy URL
-
+ {!connection ? (
+
+ {connecting ? "Connecting..." : `Connect ${label}`}
+
+ ) : (
+
+ {webhookUrl && (
+
+
+
+ Webhook URL
+
+
+
+
+
+ }
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ Copy URL
+
+
-
-
- onRegenerateToken(provider)}
- >
-
- Regenerate URL
-
- onDelete(provider)}
- >
-
- Disconnect
-
-
-
- )}
-
+
+ onRegenerateToken(provider)}
+ >
+
+ Regenerate URL
+
+ onDelete(provider)}
+ >
+
+ Disconnect
+
+
+
+ )}
+
+ )}
@@ -286,9 +278,26 @@ export function WebhookCard({
Sofa will automatically log movies and episodes when you
finish watching them
+
+ ) : isEmby ? (
+
- Make sure the username above matches your Plex account
- name
+ Open Emby, go to{" "}
+
+ Settings > Webhooks
+
+
+ Add a new webhook and paste the URL above
+
+ Enable the{" "}
+
+ Playback Stop
+ {" "}
+ event type
+
+
+ Sofa will automatically log movies and episodes when you
+ finish watching them
) : (
@@ -320,10 +329,6 @@ export function WebhookCard({
{" "}
notification type
-
- Make sure the username above matches your Jellyfin
- username
-
)}
diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx
index 09d59db..fb1c783 100644
--- a/app/(pages)/settings/page.tsx
+++ b/app/(pages)/settings/page.tsx
@@ -46,7 +46,6 @@ export default async function SettingsPage() {
id: conn.id,
provider: conn.provider,
token: conn.token,
- mediaServerUsername: conn.mediaServerUsername,
enabled: conn.enabled,
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
recentEvents: events.map((e) => ({
diff --git a/app/api/webhooks/[token]/route.ts b/app/api/webhooks/[token]/route.ts
index f00a021..f4dca9f 100644
--- a/app/api/webhooks/[token]/route.ts
+++ b/app/api/webhooks/[token]/route.ts
@@ -6,6 +6,7 @@ import { webhookConnections } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import type { WebhookEvent } from "@/lib/services/webhooks";
import {
+ parseEmbyPayload,
parseJellyfinPayload,
parsePlexPayload,
processWebhook,
@@ -36,6 +37,9 @@ export async function POST(
if (connection.provider === "plex") {
const formData = await req.formData();
event = parsePlexPayload(formData);
+ } else if (connection.provider === "emby") {
+ const body = await req.json();
+ event = parseEmbyPayload(body);
} else {
const body = await req.json();
event = parseJellyfinPayload(body);
diff --git a/drizzle/20260305054757_high_cobalt_man/migration.sql b/drizzle/20260305054757_high_cobalt_man/migration.sql
new file mode 100644
index 0000000..5456634
--- /dev/null
+++ b/drizzle/20260305054757_high_cobalt_man/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`;
\ No newline at end of file
diff --git a/drizzle/20260305054757_high_cobalt_man/snapshot.json b/drizzle/20260305054757_high_cobalt_man/snapshot.json
new file mode 100644
index 0000000..8f5af92
--- /dev/null
+++ b/drizzle/20260305054757_high_cobalt_man/snapshot.json
@@ -0,0 +1,2049 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "ced24fed-4572-4e4f-8534-d214de1e4a40",
+ "prevIds": [
+ "801f55b9-8091-42d4-b4ab-20eccf2258d9"
+ ],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "appSettings",
+ "entityType": "tables"
+ },
+ {
+ "name": "availabilityOffers",
+ "entityType": "tables"
+ },
+ {
+ "name": "cronRuns",
+ "entityType": "tables"
+ },
+ {
+ "name": "episodes",
+ "entityType": "tables"
+ },
+ {
+ "name": "seasons",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "titleRecommendations",
+ "entityType": "tables"
+ },
+ {
+ "name": "titles",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "userEpisodeWatches",
+ "entityType": "tables"
+ },
+ {
+ "name": "userMovieWatches",
+ "entityType": "tables"
+ },
+ {
+ "name": "userRatings",
+ "entityType": "tables"
+ },
+ {
+ "name": "userTitleStatus",
+ "entityType": "tables"
+ },
+ {
+ "name": "verification",
+ "entityType": "tables"
+ },
+ {
+ "name": "webhookConnections",
+ "entityType": "tables"
+ },
+ {
+ "name": "webhookEventLog",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accountId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerId",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accessToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refreshToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "idToken",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "accessTokenExpiresAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refreshTokenExpiresAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "scope",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "password",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "appSettings"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "value",
+ "entityType": "columns",
+ "table": "appSettings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'US'",
+ "generated": null,
+ "name": "region",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerId",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "providerName",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "logoPath",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "offerType",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "link",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "availabilityOffers"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "jobName",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "startedAt",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "finishedAt",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "errorMessage",
+ "entityType": "columns",
+ "table": "cronRuns"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seasonId",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "episodeNumber",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "stillPath",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "airDate",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "runtimeMinutes",
+ "entityType": "columns",
+ "table": "episodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "seasonNumber",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterPath",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "airDate",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "seasons"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "expiresAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ipAddress",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userAgent",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "impersonatedBy",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "recommendedTitleId",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "rank",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "titleRecommendations"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "tmdbId",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "originalTitle",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "overview",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "releaseDate",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "firstAirDate",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "posterPath",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "backdropPath",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "real",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "popularity",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "real",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "voteAverage",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "voteCount",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "colorPalette",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastFetchedAt",
+ "entityType": "columns",
+ "table": "titles"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "false",
+ "generated": null,
+ "name": "emailVerified",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "image",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'",
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": "false",
+ "generated": null,
+ "name": "banned",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "banReason",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "banExpires",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "episodeId",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "watchedAt",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'",
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "watchedAt",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'",
+ "generated": null,
+ "name": "source",
+ "entityType": "columns",
+ "table": "userMovieWatches"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ratingStars",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "ratedAt",
+ "entityType": "columns",
+ "table": "userRatings"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "titleId",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "addedAt",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "userTitleStatus"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "identifier",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "value",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "expiresAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "table": "verification"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "userId",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "true",
+ "generated": null,
+ "name": "enabled",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lastEventAt",
+ "entityType": "columns",
+ "table": "webhookConnections"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "connectionId",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "eventType",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "mediaType",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "mediaTitle",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "errorMessage",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "receivedAt",
+ "entityType": "columns",
+ "table": "webhookEventLog"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_account_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "account"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_availabilityOffers_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "availabilityOffers"
+ },
+ {
+ "columns": [
+ "seasonId"
+ ],
+ "tableTo": "seasons",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_episodes_seasonId_seasons_id_fk",
+ "entityType": "fks",
+ "table": "episodes"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_seasons_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "seasons"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleRecommendations_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": [
+ "recommendedTitleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userEpisodeWatches_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ "episodeId"
+ ],
+ "tableTo": "episodes",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk",
+ "entityType": "fks",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userMovieWatches_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userMovieWatches_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userRatings_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userRatings"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userRatings_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userRatings"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userTitleStatus_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": [
+ "titleId"
+ ],
+ "tableTo": "titles",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_userTitleStatus_titleId_titles_id_fk",
+ "entityType": "fks",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": [
+ "userId"
+ ],
+ "tableTo": "user",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_webhookConnections_userId_user_id_fk",
+ "entityType": "fks",
+ "table": "webhookConnections"
+ },
+ {
+ "columns": [
+ "connectionId"
+ ],
+ "tableTo": "webhookConnections",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_webhookEventLog_connectionId_webhookConnections_id_fk",
+ "entityType": "fks",
+ "table": "webhookEventLog"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "account_pk",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "key"
+ ],
+ "nameExplicit": false,
+ "name": "appSettings_pk",
+ "table": "appSettings",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "cronRuns_pk",
+ "table": "cronRuns",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "episodes_pk",
+ "table": "episodes",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "seasons_pk",
+ "table": "seasons",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "titles_pk",
+ "table": "titles",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "user_pk",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "userEpisodeWatches_pk",
+ "table": "userEpisodeWatches",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "userMovieWatches_pk",
+ "table": "userMovieWatches",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "verification_pk",
+ "table": "verification",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "webhookConnections_pk",
+ "table": "webhookConnections",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "webhookEventLog_pk",
+ "table": "webhookEventLog",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "region",
+ "isExpression": false
+ },
+ {
+ "value": "providerId",
+ "isExpression": false
+ },
+ {
+ "value": "offerType",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "availabilityOffers_unique",
+ "entityType": "indexes",
+ "table": "availabilityOffers"
+ },
+ {
+ "columns": [
+ {
+ "value": "jobName",
+ "isExpression": false
+ },
+ {
+ "value": "startedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "cronRuns_jobName_startedAt",
+ "entityType": "indexes",
+ "table": "cronRuns"
+ },
+ {
+ "columns": [
+ {
+ "value": "seasonId",
+ "isExpression": false
+ },
+ {
+ "value": "episodeNumber",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "episodes_seasonId_episodeNumber",
+ "entityType": "indexes",
+ "table": "episodes"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "seasonNumber",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "seasons_titleId_seasonNumber",
+ "entityType": "indexes",
+ "table": "seasons"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ },
+ {
+ "value": "recommendedTitleId",
+ "isExpression": false
+ },
+ {
+ "value": "source",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titleRecommendations_unique",
+ "entityType": "indexes",
+ "table": "titleRecommendations"
+ },
+ {
+ "columns": [
+ {
+ "value": "tmdbId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_tmdbId_unique",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "type",
+ "isExpression": false
+ },
+ {
+ "value": "releaseDate",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_type_releaseDate",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "type",
+ "isExpression": false
+ },
+ {
+ "value": "firstAirDate",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "titles_type_firstAirDate",
+ "entityType": "indexes",
+ "table": "titles"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "watchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userEpisodeWatches_userId_watchedAt",
+ "entityType": "indexes",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "episodeId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userEpisodeWatches_episodeId",
+ "entityType": "indexes",
+ "table": "userEpisodeWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "watchedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userMovieWatches_userId_watchedAt",
+ "entityType": "indexes",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userMovieWatches_titleId",
+ "entityType": "indexes",
+ "table": "userMovieWatches"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "userRatings_userId_titleId",
+ "entityType": "indexes",
+ "table": "userRatings"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "titleId",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "userTitleStatus_userId_titleId",
+ "entityType": "indexes",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "status",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "userTitleStatus_userId_status",
+ "entityType": "indexes",
+ "table": "userTitleStatus"
+ },
+ {
+ "columns": [
+ {
+ "value": "userId",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "webhookConnections_userId_provider",
+ "entityType": "indexes",
+ "table": "webhookConnections"
+ },
+ {
+ "columns": [
+ {
+ "value": "token",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "origin": "manual",
+ "name": "webhookConnections_token",
+ "entityType": "indexes",
+ "table": "webhookConnections"
+ },
+ {
+ "columns": [
+ {
+ "value": "connectionId",
+ "isExpression": false
+ },
+ {
+ "value": "receivedAt",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "webhookEventLog_connectionId_receivedAt",
+ "entityType": "indexes",
+ "table": "webhookEventLog"
+ },
+ {
+ "columns": [
+ "token"
+ ],
+ "nameExplicit": false,
+ "name": "session_token_unique",
+ "entityType": "uniques",
+ "table": "session"
+ },
+ {
+ "columns": [
+ "email"
+ ],
+ "nameExplicit": false,
+ "name": "user_email_unique",
+ "entityType": "uniques",
+ "table": "user"
+ }
+ ],
+ "renames": []
+}
\ No newline at end of file
diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts
index 2609f03..2b6c137 100644
--- a/lib/actions/settings.ts
+++ b/lib/actions/settings.ts
@@ -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");
}
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 07ec26f..0d57ce9 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -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" }),
diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts
index a8c9287..d1838ef 100644
--- a/lib/services/tracking.ts
+++ b/lib/services/tracking.ts
@@ -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;
diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts
index 35d1a62..9d079c2 100644
--- a/lib/services/webhooks.ts
+++ b/lib/services/webhooks.ts
@@ -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,
+): 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 | 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;
+ 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 {
@@ -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 {