From 67356e04c698b07440831acea4282971b6229f7f Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 1 Mar 2026 16:24:39 -0500 Subject: [PATCH] Add Plex and Jellyfin webhook integration for automatic watch tracking When a user finishes watching on their media server, a webhook fires and Sofa logs it as watched, triggering all existing auto-transitions. Each user configures their connection in settings and gets a unique webhook URL. - Add webhookConnections and webhookEventLog schema tables - Add TMDB findByExternalId for resolving IMDB/TVDB IDs - Add source parameter to tracking functions (plex/jellyfin) - Add webhook processing service with payload parsers, title resolution, and deduplication - Add public webhook receiver route (token-based auth) - Add authenticated settings API routes for managing connections - Add Media Servers section to settings UI with Plex/Jellyfin cards Co-Authored-By: Claude Opus 4.6 --- app/(pages)/settings/page.tsx | 389 +++- .../webhooks/regenerate-token/route.ts | 43 + app/api/settings/webhooks/route.ts | 124 ++ app/api/webhooks/[token]/route.ts | 57 + components/icons/media-servers.tsx | 39 + .../20260301210600_minor_karnak/migration.sql | 27 + .../20260301210600_minor_karnak/snapshot.json | 1948 +++++++++++++++++ lib/db/schema.ts | 57 +- lib/services/tracking.ts | 33 +- lib/services/webhooks.ts | 407 ++++ lib/tmdb/client.ts | 10 + lib/tmdb/types.ts | 12 + 12 files changed, 3132 insertions(+), 14 deletions(-) create mode 100644 app/api/settings/webhooks/regenerate-token/route.ts create mode 100644 app/api/settings/webhooks/route.ts create mode 100644 app/api/webhooks/[token]/route.ts create mode 100644 components/icons/media-servers.tsx create mode 100644 drizzle/20260301210600_minor_karnak/migration.sql create mode 100644 drizzle/20260301210600_minor_karnak/snapshot.json create mode 100644 lib/services/webhooks.ts diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 08d8dd1..4fa8c34 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -1,21 +1,35 @@ "use client"; import { + IconCheck, + IconChevronDown, + IconCopy, IconLogout, + IconRefresh, IconSettings, IconShieldLock, + IconTrash, IconUser, IconUserPlus, + IconWebhook, } from "@tabler/icons-react"; -import { motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { JellyfinIcon, PlexIcon } from "@/components/icons/media-servers"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle, } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { signOut, useSession } from "@/lib/auth/client"; @@ -28,12 +42,291 @@ const sectionVariants = { }, }; +interface WebhookConnection { + id: string; + provider: "plex" | "jellyfin"; + token: string; + mediaServerUsername: string; + enabled: boolean; + lastEventAt: string | null; + recentEvents: { + id: string; + eventType: string | null; + mediaType: string | null; + mediaTitle: string | null; + status: "success" | "ignored" | "error"; + receivedAt: string; + }[]; +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function WebhookCard({ + provider, + connection, + onSave, + onDelete, + onRegenerateToken, + onToggle, +}: { + provider: "plex" | "jellyfin"; + connection: WebhookConnection | null; + onSave: (provider: "plex" | "jellyfin", username: string) => Promise; + onDelete: (provider: "plex" | "jellyfin") => Promise; + onRegenerateToken: (provider: "plex" | "jellyfin") => Promise; + onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise; +}) { + const [username, setUsername] = useState( + connection?.mediaServerUsername ?? "", + ); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + + const isPlex = provider === "plex"; + const label = isPlex ? "Plex" : "Jellyfin"; + const Icon = isPlex ? PlexIcon : JellyfinIcon; + + const webhookUrl = connection + ? `${window.location.origin}/api/webhooks/${connection.token}` + : null; + + async function handleSave() { + if (!username.trim()) return; + setSaving(true); + try { + await onSave(provider, username.trim()); + } finally { + setSaving(false); + } + } + + async function handleCopy() { + if (!webhookUrl) return; + await navigator.clipboard.writeText(webhookUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + +
+
+
+ +
+
+ {label} + + {connection + ? connection.lastEventAt + ? `Last event ${timeAgo(connection.lastEventAt)}` + : "Connected — no events yet" + : "Not configured"} + +
+
+ {connection && ( + onToggle(provider, checked)} + /> + )} +
+
+ + +
+ +
+ setUsername(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + /> + {!connection ? ( + + ) : ( + username.trim() !== connection.mediaServerUsername && ( + + ) + )} +
+
+ + + {webhookUrl && ( + +
+ +
+ + +
+
+ +
+ + +
+
+ )} +
+ + + + + Setup instructions + + +
+ {isPlex ? ( +
    +
  1. + Open Plex, go to{" "} + + Settings > Webhooks + +
  2. +
  3. + Click{" "} + + Add Webhook + {" "} + and paste the URL above +
  4. +
  5. + Sofa will automatically log movies and episodes when you + finish watching them +
  6. +
  7. + Make sure the username above matches your Plex account name +
  8. +
+ ) : ( +
    +
  1. + Install the{" "} + + Webhook plugin + {" "} + from Jellyfin's plugin catalog +
  2. +
  3. + Go to{" "} + + Dashboard > Plugins > Webhook + +
  4. +
  5. + Add a{" "} + + Generic Destination + {" "} + and paste the URL above +
  6. +
  7. + Enable the{" "} + + Playback Stop + {" "} + notification type +
  8. +
  9. + Make sure the username above matches your Jellyfin username +
  10. +
+ )} +
+
+
+
+
+ ); +} + export default function SettingsPage() { const { data: session, isPending } = useSession(); const router = useRouter(); const [registrationOpen, setRegistrationOpen] = useState(false); const [loadingSettings, setLoadingSettings] = useState(true); const [toggling, setToggling] = useState(false); + const [webhookConnections, setWebhookConnections] = useState< + WebhookConnection[] + >([]); const isAdmin = session?.user?.role === "admin"; @@ -49,18 +342,29 @@ export default function SettingsPage() { } }, []); + const fetchWebhooks = useCallback(async () => { + try { + const res = await fetch("/api/settings/webhooks"); + if (res.ok) { + const data = await res.json(); + setWebhookConnections(data.connections); + } + } catch {} + }, []); + useEffect(() => { if (isPending) return; if (!session?.user) { router.replace("/login"); return; } + fetchWebhooks(); if (session.user.role === "admin") { fetchSettings(); } else { setLoadingSettings(false); } - }, [session, isPending, router, fetchSettings]); + }, [session, isPending, router, fetchSettings, fetchWebhooks]); async function handleToggleRegistration(checked: boolean) { setToggling(true); @@ -78,6 +382,54 @@ export default function SettingsPage() { } } + async function handleSaveWebhook( + provider: "plex" | "jellyfin", + mediaServerUsername: string, + ) { + const res = await fetch("/api/settings/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, mediaServerUsername }), + }); + if (res.ok) await fetchWebhooks(); + } + + async function handleDeleteWebhook(provider: "plex" | "jellyfin") { + const res = await fetch("/api/settings/webhooks", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + if (res.ok) await fetchWebhooks(); + } + + async function handleRegenerateToken(provider: "plex" | "jellyfin") { + const res = await fetch("/api/settings/webhooks/regenerate-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + if (res.ok) await fetchWebhooks(); + } + + async function handleToggleWebhook( + provider: "plex" | "jellyfin", + enabled: boolean, + ) { + await fetch("/api/settings/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + mediaServerUsername: + webhookConnections.find((c) => c.provider === provider) + ?.mediaServerUsername ?? "", + enabled, + }), + }); + await fetchWebhooks(); + } + if (isPending || loadingSettings) { return
; } @@ -90,6 +442,11 @@ export default function SettingsPage() { ); const initial = session.user.name?.charAt(0).toUpperCase() ?? "?"; + const plexConnection = + webhookConnections.find((c) => c.provider === "plex") ?? null; + const jellyfinConnection = + webhookConnections.find((c) => c.provider === "jellyfin") ?? null; + return ( + {/* Media Servers section */} + +
+ +

+ Media Servers +

+
+
+ + +
+
+ {/* Administration section — admin only */} {isAdmin && ( diff --git a/app/api/settings/webhooks/regenerate-token/route.ts b/app/api/settings/webhooks/regenerate-token/route.ts new file mode 100644 index 0000000..d533d36 --- /dev/null +++ b/app/api/settings/webhooks/regenerate-token/route.ts @@ -0,0 +1,43 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { db } from "@/lib/db/client"; +import { webhookConnections } from "@/lib/db/schema"; + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await request.json(); + const { provider } = body; + + if (!provider || !["plex", "jellyfin"].includes(provider)) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + const newToken = crypto.randomBytes(32).toString("hex"); + + const connection = await db + .update(webhookConnections) + .set({ token: newToken }) + .where( + and( + eq(webhookConnections.userId, session.user.id), + eq(webhookConnections.provider, provider), + ), + ) + .returning() + .get(); + + if (!connection) { + return NextResponse.json( + { error: "Connection not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ connection }); +} diff --git a/app/api/settings/webhooks/route.ts b/app/api/settings/webhooks/route.ts new file mode 100644 index 0000000..f3f09a5 --- /dev/null +++ b/app/api/settings/webhooks/route.ts @@ -0,0 +1,124 @@ +import crypto from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { db } from "@/lib/db/client"; +import { webhookConnections, webhookEventLog } from "@/lib/db/schema"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const connections = await db + .select() + .from(webhookConnections) + .where(eq(webhookConnections.userId, session.user.id)) + .all(); + + // Fetch recent events for each connection + const connectionsWithEvents = await Promise.all( + connections.map(async (conn) => { + const events = await db + .select() + .from(webhookEventLog) + .where(eq(webhookEventLog.connectionId, conn.id)) + .orderBy(desc(webhookEventLog.receivedAt)) + .limit(10) + .all(); + return { ...conn, recentEvents: events }; + }), + ); + + return NextResponse.json({ connections: connectionsWithEvents }); +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await request.json(); + const { provider, mediaServerUsername, enabled } = body; + + if (!provider || !["plex", "jellyfin"].includes(provider)) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + if (!mediaServerUsername || typeof mediaServerUsername !== "string") { + return NextResponse.json( + { error: "Media server username is required" }, + { status: 400 }, + ); + } + + // Check if connection already exists + const existing = await db + .select() + .from(webhookConnections) + .where( + and( + eq(webhookConnections.userId, session.user.id), + eq(webhookConnections.provider, provider), + ), + ) + .get(); + + if (existing) { + // Update existing — preserve token, update username and enabled + const connection = await db + .update(webhookConnections) + .set({ + mediaServerUsername: mediaServerUsername.trim(), + enabled: typeof enabled === "boolean" ? enabled : existing.enabled, + }) + .where(eq(webhookConnections.id, existing.id)) + .returning() + .get(); + return NextResponse.json({ connection }); + } + + // Create new connection + const token = crypto.randomBytes(32).toString("hex"); + const now = new Date(); + + const connection = await db + .insert(webhookConnections) + .values({ + userId: session.user.id, + provider, + token, + mediaServerUsername: mediaServerUsername.trim(), + enabled: true, + createdAt: now, + }) + .returning() + .get(); + + return NextResponse.json({ connection }); +} + +export async function DELETE(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await request.json(); + const { provider } = body; + + if (!provider || !["plex", "jellyfin"].includes(provider)) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + await db + .delete(webhookConnections) + .where( + and( + eq(webhookConnections.userId, session.user.id), + eq(webhookConnections.provider, provider), + ), + ) + .run(); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/webhooks/[token]/route.ts b/app/api/webhooks/[token]/route.ts new file mode 100644 index 0000000..b400741 --- /dev/null +++ b/app/api/webhooks/[token]/route.ts @@ -0,0 +1,57 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { webhookConnections } from "@/lib/db/schema"; +import type { WebhookEvent } from "@/lib/services/webhooks"; +import { + parseJellyfinPayload, + parsePlexPayload, + processWebhook, +} from "@/lib/services/webhooks"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + + // Look up connection by token — this IS the auth + const connection = await db + .select() + .from(webhookConnections) + .where(eq(webhookConnections.token, token)) + .get(); + + if (!connection || !connection.enabled) { + // Always return 200 to avoid retry storms from media servers + return NextResponse.json({ ok: true }); + } + + try { + let event: WebhookEvent | null; + if (connection.provider === "plex") { + const formData = await req.formData(); + event = parsePlexPayload(formData); + } else { + const body = await req.json(); + event = parseJellyfinPayload(body); + } + + if (!event) { + // Not a relevant event type — silently ignore + return NextResponse.json({ ok: true }); + } + + await processWebhook( + connection.id, + connection.userId, + connection.provider, + event, + ); + } catch { + // Swallow errors — never return non-200 to media servers + } + + return NextResponse.json({ ok: true }); +} diff --git a/components/icons/media-servers.tsx b/components/icons/media-servers.tsx new file mode 100644 index 0000000..309a7c5 --- /dev/null +++ b/components/icons/media-servers.tsx @@ -0,0 +1,39 @@ +import type { SVGProps } from "react"; + +export function PlexIcon(props: SVGProps) { + return ( + + ); +} + +export function JellyfinIcon(props: SVGProps) { + return ( + + ); +} diff --git a/drizzle/20260301210600_minor_karnak/migration.sql b/drizzle/20260301210600_minor_karnak/migration.sql new file mode 100644 index 0000000..3eb687a --- /dev/null +++ b/drizzle/20260301210600_minor_karnak/migration.sql @@ -0,0 +1,27 @@ +CREATE TABLE `webhookConnections` ( + `id` text PRIMARY KEY, + `userId` text NOT NULL, + `provider` text NOT NULL, + `token` text NOT NULL, + `mediaServerUsername` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `createdAt` integer NOT NULL, + `lastEventAt` integer, + CONSTRAINT `fk_webhookConnections_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `webhookEventLog` ( + `id` text PRIMARY KEY, + `connectionId` text NOT NULL, + `eventType` text, + `mediaType` text, + `mediaTitle` text, + `status` text NOT NULL, + `errorMessage` text, + `receivedAt` integer NOT NULL, + CONSTRAINT `fk_webhookEventLog_connectionId_webhookConnections_id_fk` FOREIGN KEY (`connectionId`) REFERENCES `webhookConnections`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `webhookConnections_userId_provider` ON `webhookConnections` (`userId`,`provider`);--> statement-breakpoint +CREATE UNIQUE INDEX `webhookConnections_token` ON `webhookConnections` (`token`);--> statement-breakpoint +CREATE INDEX `webhookEventLog_connectionId_receivedAt` ON `webhookEventLog` (`connectionId`,`receivedAt`); \ No newline at end of file diff --git a/drizzle/20260301210600_minor_karnak/snapshot.json b/drizzle/20260301210600_minor_karnak/snapshot.json new file mode 100644 index 0000000..5210a43 --- /dev/null +++ b/drizzle/20260301210600_minor_karnak/snapshot.json @@ -0,0 +1,1948 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "406b2cf8-d2eb-4113-840a-d2cec54dbadc", + "prevIds": [ + "5a3a9362-3a1d-4db9-b726-5791fa0f8816" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "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": "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": "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": "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": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaServerUsername", + "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": "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": "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/db/schema.ts b/lib/db/schema.ts index d75c788..aa6a22f 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -179,7 +179,9 @@ export const userMovieWatches = sqliteTable( .notNull() .references(() => titles.id, { onDelete: "cascade" }), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), - source: text("source", { enum: ["manual", "import"] }) + source: text("source", { + enum: ["manual", "import", "plex", "jellyfin"], + }) .notNull() .default("manual"), }, @@ -203,7 +205,9 @@ export const userEpisodeWatches = sqliteTable( .notNull() .references(() => episodes.id, { onDelete: "cascade" }), watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(), - source: text("source", { enum: ["manual", "import"] }) + source: text("source", { + enum: ["manual", "import", "plex", "jellyfin"], + }) .notNull() .default("manual"), }, @@ -283,6 +287,55 @@ export const titleRecommendations = sqliteTable( ], ); +// ─── Webhook Connections ───────────────────────────────────────────── + +export const webhookConnections = sqliteTable( + "webhookConnections", + { + id: uuidPk(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + provider: text("provider", { enum: ["plex", "jellyfin"] }).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" }), + }, + (table) => [ + uniqueIndex("webhookConnections_userId_provider").on( + table.userId, + table.provider, + ), + uniqueIndex("webhookConnections_token").on(table.token), + ], +); + +export const webhookEventLog = sqliteTable( + "webhookEventLog", + { + id: uuidPk(), + connectionId: text("connectionId") + .notNull() + .references(() => webhookConnections.id, { onDelete: "cascade" }), + eventType: text("eventType"), + mediaType: text("mediaType"), + mediaTitle: text("mediaTitle"), + status: text("status", { + enum: ["success", "ignored", "error"], + }).notNull(), + errorMessage: text("errorMessage"), + receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(), + }, + (table) => [ + index("webhookEventLog_connectionId_receivedAt").on( + table.connectionId, + table.receivedAt, + ), + ], +); + // ─── App Settings ─────────────────────────────────────────────────── export const appSettings = sqliteTable("appSettings", { diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 43740e4..93a3e03 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -14,6 +14,7 @@ export async function setTitleStatus( userId: string, titleId: string, status: "watchlist" | "in_progress" | "completed", + source: "manual" | "import" | "plex" | "jellyfin" = "manual", ) { const now = new Date(); await db @@ -26,7 +27,7 @@ export async function setTitleStatus( .run(); if (status === "completed") { - await markAllEpisodesWatched(userId, titleId); + await markAllEpisodesWatched(userId, titleId, source); } } @@ -42,11 +43,15 @@ export async function removeTitleStatus(userId: string, titleId: string) { .run(); } -export async function logMovieWatch(userId: string, titleId: string) { +export async function logMovieWatch( + userId: string, + titleId: string, + source: "manual" | "import" | "plex" | "jellyfin" = "manual", +) { const now = new Date(); await db .insert(userMovieWatches) - .values({ userId, titleId, watchedAt: now, source: "manual" }) + .values({ userId, titleId, watchedAt: now, source }) .run(); // Auto-set status to completed @@ -62,17 +67,21 @@ export async function logMovieWatch(userId: string, titleId: string) { .get(); if (!existing) { - await setTitleStatus(userId, titleId, "completed"); + await setTitleStatus(userId, titleId, "completed", source); } else if (existing.status !== "completed") { - await setTitleStatus(userId, titleId, "completed"); + await setTitleStatus(userId, titleId, "completed", source); } } -export async function logEpisodeWatch(userId: string, episodeId: string) { +export async function logEpisodeWatch( + userId: string, + episodeId: string, + source: "manual" | "import" | "plex" | "jellyfin" = "manual", +) { const now = new Date(); await db .insert(userEpisodeWatches) - .values({ userId, episodeId, watchedAt: now, source: "manual" }) + .values({ userId, episodeId, watchedAt: now, source }) .run(); // Find the title for this episode @@ -103,14 +112,18 @@ export async function logEpisodeWatch(userId: string, episodeId: string) { .get(); if (!existing) { - await setTitleStatus(userId, titleId, "in_progress"); + await setTitleStatus(userId, titleId, "in_progress", source); } // Check if all episodes are watched -> auto-complete await checkAllEpisodesWatched(userId, titleId); } -async function markAllEpisodesWatched(userId: string, titleId: string) { +async function markAllEpisodesWatched( + userId: string, + titleId: string, + source: "manual" | "import" | "plex" | "jellyfin" = "manual", +) { const title = await db .select() .from(titles) @@ -150,7 +163,7 @@ async function markAllEpisodesWatched(userId: string, titleId: string) { userId, episodeId: ep.id, watchedAt: now, - source: "manual", + source, }) .run(); } diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts new file mode 100644 index 0000000..d99a496 --- /dev/null +++ b/lib/services/webhooks.ts @@ -0,0 +1,407 @@ +import { and, eq, gte } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { + episodes, + seasons, + userEpisodeWatches, + userMovieWatches, + webhookConnections, + webhookEventLog, +} from "@/lib/db/schema"; +import { findByExternalId, searchTv } from "@/lib/tmdb/client"; +import { importTitle } from "./metadata"; +import { logEpisodeWatch, logMovieWatch } from "./tracking"; + +// ─── Types ────────────────────────────────────────────────────────── + +export interface WebhookEvent { + provider: "plex" | "jellyfin"; + mediaType: "movie" | "episode"; + title: string; + tmdbId?: number; + imdbId?: string; + tvdbId?: string; + seasonNumber?: number; + episodeNumber?: number; + showTitle?: string; +} + +// ─── Payload Parsers ──────────────────────────────────────────────── + +export function parsePlexPayload(formData: FormData): WebhookEvent | null { + const raw = formData.get("payload"); + if (!raw || typeof raw !== "string") return null; + + let payload: Record; + try { + payload = JSON.parse(raw); + } catch { + return null; + } + + if (payload.event !== "media.scrobble") return null; + + const metadata = payload.Metadata as Record | undefined; + if (!metadata) return null; + + const metaType = metadata.type as string; + const isMovie = metaType === "movie"; + const isEpisode = metaType === "episode"; + if (!isMovie && !isEpisode) return null; + + // Extract external IDs from Guid array + const guids = (metadata.Guid ?? metadata.guid) as + | { id: string }[] + | undefined; + let tmdbId: number | undefined; + let imdbId: string | undefined; + let tvdbId: string | undefined; + + if (Array.isArray(guids)) { + for (const g of guids) { + const id = g.id ?? ""; + if (id.startsWith("tmdb://")) tmdbId = Number.parseInt(id.slice(7), 10); + else if (id.startsWith("imdb://")) imdbId = id.slice(7); + else if (id.startsWith("tvdb://")) tvdbId = id.slice(7); + } + } + + return { + provider: "plex", + mediaType: isMovie ? "movie" : "episode", + title: (metadata.title ?? metadata.Title ?? "") as string, + tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined, + imdbId, + tvdbId, + seasonNumber: metadata.parentIndex as number | undefined, + episodeNumber: metadata.index as number | undefined, + showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as + | string + | undefined, + }; +} + +export function parseJellyfinPayload( + body: Record, +): WebhookEvent | null { + const notifType = body.NotificationType as string | undefined; + if (notifType !== "PlaybackStop") return null; + if (body.PlayedToCompletion !== true) return null; + + const itemType = body.ItemType as string | undefined; + const isMovie = itemType === "Movie"; + const isEpisode = itemType === "Episode"; + if (!isMovie && !isEpisode) return null; + + const tmdbRaw = body.Provider_tmdb as string | undefined; + const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined; + + return { + provider: "jellyfin", + mediaType: isMovie ? "movie" : "episode", + title: (body.Name ?? "") as string, + tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined, + imdbId: (body.Provider_imdb as string) || undefined, + tvdbId: (body.Provider_tvdb as string) || undefined, + seasonNumber: body.SeasonNumber as number | undefined, + episodeNumber: body.EpisodeNumber as number | undefined, + showTitle: (body.SeriesName ?? body.ShowName) as string | undefined, + }; +} + +// ─── Title Resolution ─────────────────────────────────────────────── + +async function resolveMovieTmdbId(event: WebhookEvent): Promise { + if (event.tmdbId) return event.tmdbId; + + if (event.imdbId) { + const result = await findByExternalId(event.imdbId, "imdb_id"); + if (result.movie_results.length > 0) return result.movie_results[0].id; + } + + if (event.tvdbId) { + const result = await findByExternalId(event.tvdbId, "tvdb_id"); + if (result.movie_results.length > 0) return result.movie_results[0].id; + } + + return null; +} + +async function resolveEpisode(event: WebhookEvent): Promise<{ + showTmdbId: number; + seasonNumber: number; + episodeNumber: number; +} | null> { + const seasonNumber = event.seasonNumber; + const episodeNumber = event.episodeNumber; + if (seasonNumber == null || episodeNumber == null) return null; + + // Strategy 1: Use IMDB ID to find the episode and get show_id + if (event.imdbId) { + const result = await findByExternalId(event.imdbId, "imdb_id"); + if (result.tv_episode_results.length > 0) { + return { + showTmdbId: result.tv_episode_results[0].show_id, + seasonNumber, + episodeNumber, + }; + } + // IMDB ID might reference the show itself + if (result.tv_results.length > 0) { + return { + showTmdbId: result.tv_results[0].id, + seasonNumber, + episodeNumber, + }; + } + } + + // Strategy 2: Use TVDB ID + if (event.tvdbId) { + const result = await findByExternalId(event.tvdbId, "tvdb_id"); + if (result.tv_episode_results.length > 0) { + return { + showTmdbId: result.tv_episode_results[0].show_id, + seasonNumber, + episodeNumber, + }; + } + if (result.tv_results.length > 0) { + return { + showTmdbId: result.tv_results[0].id, + seasonNumber, + episodeNumber, + }; + } + } + + // Strategy 3: Use TMDB ID directly if it's the show ID + if (event.tmdbId) { + return { showTmdbId: event.tmdbId, seasonNumber, episodeNumber }; + } + + // Strategy 4: Search by show title + if (event.showTitle) { + const searchResult = await searchTv(event.showTitle); + if (searchResult.results.length > 0) { + return { + showTmdbId: searchResult.results[0].id, + seasonNumber, + episodeNumber, + }; + } + } + + return null; +} + +// ─── Deduplication ────────────────────────────────────────────────── + +async function isDuplicateMovieWatch( + userId: string, + titleId: string, +): Promise { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const recent = await db + .select() + .from(userMovieWatches) + .where( + and( + eq(userMovieWatches.userId, userId), + eq(userMovieWatches.titleId, titleId), + gte(userMovieWatches.watchedAt, fiveMinutesAgo), + ), + ) + .get(); + return !!recent; +} + +async function isDuplicateEpisodeWatch( + userId: string, + episodeId: string, +): Promise { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const recent = await db + .select() + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + eq(userEpisodeWatches.episodeId, episodeId), + gte(userEpisodeWatches.watchedAt, fiveMinutesAgo), + ), + ) + .get(); + return !!recent; +} + +// ─── Event Logging ────────────────────────────────────────────────── + +async function logEvent( + connectionId: string, + event: WebhookEvent | null, + status: "success" | "ignored" | "error", + errorMessage?: string, +) { + await db + .insert(webhookEventLog) + .values({ + connectionId, + eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop", + mediaType: event?.mediaType ?? null, + mediaTitle: event?.title ?? null, + status, + errorMessage: errorMessage ?? null, + receivedAt: new Date(), + }) + .run(); + + await db + .update(webhookConnections) + .set({ lastEventAt: new Date() }) + .where(eq(webhookConnections.id, connectionId)) + .run(); +} + +// ─── Main Processing ──────────────────────────────────────────────── + +export async function processWebhook( + connectionId: string, + userId: string, + provider: "plex" | "jellyfin", + event: WebhookEvent, +): Promise<{ status: "success" | "ignored" | "error"; message: string }> { + try { + if (event.mediaType === "movie") { + const tmdbId = await resolveMovieTmdbId(event); + if (!tmdbId) { + await logEvent( + connectionId, + event, + "error", + "Could not resolve TMDB ID for movie", + ); + return { status: "error", message: "Could not resolve TMDB ID" }; + } + + const title = await importTitle(tmdbId, "movie"); + if (!title) { + await logEvent(connectionId, event, "error", "Failed to import movie"); + return { status: "error", message: "Failed to import movie" }; + } + + if (await isDuplicateMovieWatch(userId, title.id)) { + await logEvent( + connectionId, + event, + "ignored", + "Duplicate watch within 5 minutes", + ); + return { status: "ignored", message: "Duplicate watch" }; + } + + await logMovieWatch(userId, title.id, provider); + await logEvent(connectionId, event, "success"); + return { status: "success", message: `Logged watch for ${event.title}` }; + } + + if (event.mediaType === "episode") { + const resolved = await resolveEpisode(event); + if (!resolved) { + await logEvent( + connectionId, + event, + "error", + "Could not resolve episode", + ); + return { status: "error", message: "Could not resolve episode" }; + } + + const title = await importTitle(resolved.showTmdbId, "tv"); + if (!title) { + await logEvent( + connectionId, + event, + "error", + "Failed to import TV show", + ); + return { status: "error", message: "Failed to import TV show" }; + } + + // Find the episode in our DB + const season = await db + .select() + .from(seasons) + .where( + and( + eq(seasons.titleId, title.id), + eq(seasons.seasonNumber, resolved.seasonNumber), + ), + ) + .get(); + + if (!season) { + await logEvent( + connectionId, + event, + "error", + `Season ${resolved.seasonNumber} not found`, + ); + return { + status: "error", + message: `Season ${resolved.seasonNumber} not found`, + }; + } + + const episode = await db + .select() + .from(episodes) + .where( + and( + eq(episodes.seasonId, season.id), + eq(episodes.episodeNumber, resolved.episodeNumber), + ), + ) + .get(); + + if (!episode) { + await logEvent( + connectionId, + event, + "error", + `S${resolved.seasonNumber}E${resolved.episodeNumber} not found`, + ); + return { + status: "error", + message: `S${resolved.seasonNumber}E${resolved.episodeNumber} not found`, + }; + } + + if (await isDuplicateEpisodeWatch(userId, episode.id)) { + await logEvent( + connectionId, + event, + "ignored", + "Duplicate watch within 5 minutes", + ); + return { status: "ignored", message: "Duplicate watch" }; + } + + await logEpisodeWatch(userId, episode.id, provider); + await logEvent(connectionId, event, "success"); + return { status: "success", message: `Logged watch for ${event.title}` }; + } + + await logEvent( + connectionId, + event, + "ignored", + `Unsupported media type: ${event.mediaType}`, + ); + return { status: "ignored", message: "Unsupported media type" }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + await logEvent(connectionId, event, "error", message).catch(() => {}); + return { status: "error", message }; + } +} diff --git a/lib/tmdb/client.ts b/lib/tmdb/client.ts index bf65c12..3e392d2 100644 --- a/lib/tmdb/client.ts +++ b/lib/tmdb/client.ts @@ -1,4 +1,5 @@ import type { + TmdbFindResult, TmdbGenreListResponse, TmdbMovieDetails, TmdbRecommendationResponse, @@ -131,4 +132,13 @@ export async function discover( ); } +export async function findByExternalId( + externalId: string, + source: "imdb_id" | "tvdb_id", +) { + return tmdbFetch(`/find/${externalId}`, { + external_source: source, + }); +} + export { tmdbImageUrl } from "./image"; diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts index e24a4b4..c98c4ee 100644 --- a/lib/tmdb/types.ts +++ b/lib/tmdb/types.ts @@ -111,6 +111,18 @@ export interface TmdbRecommendationResponse { total_results: number; } +export interface TmdbFindResult { + movie_results: TmdbSearchResult[]; + tv_results: TmdbSearchResult[]; + tv_episode_results: { + id: number; + episode_number: number; + name: string; + season_number: number; + show_id: number; + }[]; +} + export interface TmdbGenre { id: number; name: string;