diff --git a/app/(pages)/settings/_components/icons.tsx b/app/(pages)/settings/_components/icons.tsx index 3839d24..b6b7588 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 JellyfinIcon(props: SVGProps) { + return ( + + ); +} + export function EmbyIcon(props: SVGProps) { return ( ) { ); } -export function JellyfinIcon(props: SVGProps) { +export function SonarrIcon(props: SVGProps) { return ( ) { aria-hidden="true" {...props} > - {/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */} + {/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */} + + ); +} + +export function RadarrIcon(props: SVGProps) { + return ( + ); diff --git a/app/(pages)/settings/_components/integration-card.tsx b/app/(pages)/settings/_components/integration-card.tsx new file mode 100644 index 0000000..7a0e0bd --- /dev/null +++ b/app/(pages)/settings/_components/integration-card.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { + IconBook2, + IconCheck, + IconChevronDown, + IconCopy, + IconExternalLink, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { formatDistanceToNow } from "date-fns"; +import { AnimatePresence, motion } from "motion/react"; +import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useConnectionActions } from "@/lib/atoms/integrations"; + +// ─── Types ────────────────────────────────────────────────────────── + +export interface IntegrationConnection { + id: string; + provider: string; + type: "webhook" | "list"; + token: string; + enabled: boolean; + lastEventAt: string | null; + recentEvents: { + id: string; + eventType: string | null; + mediaType: string | null; + mediaTitle: string | null; + status: "success" | "ignored" | "error"; + receivedAt: string; + }[]; +} + +export interface IntegrationConfig { + provider: string; + label: string; + icon: ComponentType<{ className?: string }>; + /** Build the URL from the connection token. */ + buildUrl: (token: string) => string; + /** Label shown above the URL input. */ + urlLabel: string; + /** One-line status shown below the title when connected. */ + connectedStatus: (lastEventAt: string | null) => string; + /** Optional alert banner shown at the top of the expanded card. */ + alert?: ReactNode; + /** Setup instruction steps (rendered inside an
    ). */ + setupSteps: ReactNode; + /** Optional docs link shown after setup steps. */ + docsUrl?: string; +} + +// ─── Component ────────────────────────────────────────────────────── + +export function IntegrationCard({ config }: { config: IntegrationConfig }) { + const { connection, handleConnect, handleDelete, handleRegenerateToken } = + useConnectionActions(config.provider, config.label); + const [connecting, setConnecting] = useState(false); + const [copied, setCopied] = useState(false); + const [cardOpen, setCardOpen] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + + const Icon = config.icon; + + const url = + connection && typeof window !== "undefined" + ? config.buildUrl(connection.token) + : null; + + async function onConnect() { + setConnecting(true); + try { + await handleConnect(); + } finally { + setConnecting(false); + } + } + + async function handleCopy() { + if (!url) return; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + + +
    +
    + +
    +
    + {config.label} + + {connection + ? config.connectedStatus(connection.lastEventAt) + : "Not configured"} + +
    +
    + +
    +
    + + + + {config.alert} + + {!connection ? ( + + ) : ( + + {url && ( + +
    + + + + + + + } + > + {copied ? ( + + ) : ( + + )} + + Copy URL + + + +
    + +
    + + +
    +
    + )} +
    + )} + + + + + Setup instructions + + +
    +
      + {config.setupSteps} +
    + {config.docsUrl && ( +

    + + Need more help?{" "} + + Open docs{" "} + + +

    + )} +
    +
    +
    +
    +
    +
    +
    + ); +} + +// ─── Helpers for config authoring ─────────────────────────────────── + +/** Status line for webhook integrations (shows last event time). */ +export function webhookStatus(lastEventAt: string | null): string { + return lastEventAt + ? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` + : "Ready \u2014 nothing received yet"; +} + +/** Status line for list integrations (shows last event time). */ +export function listStatus(lastEventAt: string | null): string { + return lastEventAt + ? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}` + : "Ready \u2014 not polled yet"; +} diff --git a/app/(pages)/settings/_components/integration-configs.tsx b/app/(pages)/settings/_components/integration-configs.tsx new file mode 100644 index 0000000..78f5a89 --- /dev/null +++ b/app/(pages)/settings/_components/integration-configs.tsx @@ -0,0 +1,247 @@ +import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + EmbyIcon, + JellyfinIcon, + PlexIcon, + RadarrIcon, + SonarrIcon, +} from "./icons"; +import type { IntegrationConfig } from "./integration-card"; +import { listStatus, webhookStatus } from "./integration-card"; + +function origin() { + return typeof window !== "undefined" ? window.location.origin : ""; +} + +/** Reusable alert banner for integrations that require a subscription. */ +function RequirementAlert({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); +} + +export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ + // ─── Webhook integrations ─────────────────────────────────────── + { + provider: "plex", + label: "Plex", + icon: PlexIcon, + buildUrl: (token) => `${origin()}/api/webhooks/${token}`, + urlLabel: "Webhook URL", + connectedStatus: webhookStatus, + alert: ( + + Requires an active{" "} + + Plex Pass + + {" "} + subscription. + + ), + setupSteps: ( + <> +
  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. + + ), + docsUrl: "https://support.plex.tv/hc/en-us/articles/115002267687-Webhooks/", + }, + { + provider: "jellyfin", + label: "Jellyfin", + icon: JellyfinIcon, + buildUrl: (token) => `${origin()}/api/webhooks/${token}`, + urlLabel: "Webhook URL", + connectedStatus: webhookStatus, + setupSteps: ( + <> +
  7. + Install the{" "} + + Webhook plugin + + {" "} + from Jellyfin's plugin catalog +
  8. +
  9. + Go to{" "} + + Dashboard > Plugins > Webhook + +
  10. +
  11. + Add a{" "} + + Generic Destination + {" "} + and paste the URL above +
  12. +
  13. + Enable the{" "} + Playback Stop{" "} + notification type +
  14. +
  15. + Sofa will automatically log movies and episodes when you finish + watching them +
  16. + + ), + docsUrl: "https://jellyfin.org/docs/general/server/notifications/", + }, + { + provider: "emby", + label: "Emby", + icon: EmbyIcon, + buildUrl: (token) => `${origin()}/api/webhooks/${token}`, + urlLabel: "Webhook URL", + connectedStatus: webhookStatus, + alert: ( + + Requires{" "} + Emby Server 4.7.9+{" "} + and an active{" "} + + Emby Premiere + + {" "} + license. + + ), + setupSteps: ( + <> +
  17. + Open Emby, go to{" "} + + Settings > Webhooks + +
  18. +
  19. Add a new webhook and paste the URL above
  20. +
  21. + Enable the{" "} + Playback event + category +
  22. +
  23. + Sofa will automatically log movies and episodes when you finish + watching them +
  24. + + ), + docsUrl: "https://emby.media/support/articles/Webhooks.html", + }, + + // ─── List integrations ────────────────────────────────────────── + { + provider: "sonarr", + label: "Sonarr", + icon: SonarrIcon, + buildUrl: (token) => `${origin()}/api/lists/${token}`, + urlLabel: "Sonarr List URL", + connectedStatus: listStatus, + setupSteps: ( + <> +
  25. + Open Sonarr, go to{" "} + + Settings > Import Lists + +
  26. +
  27. + Click + and + select{" "} + Custom Lists +
  28. +
  29. Paste the Sonarr URL above into the List URL field
  30. +
  31. Set your preferred quality profile and root folder
  32. +
  33. + Titles on your Sofa watchlist will be automatically added for download + when Sonarr polls this list (every 6 hours by default) +
  34. + + ), + docsUrl: "https://wiki.servarr.com/sonarr/settings#import-lists", + }, + { + provider: "radarr", + label: "Radarr", + icon: RadarrIcon, + buildUrl: (token) => `${origin()}/api/lists/${token}`, + urlLabel: "Radarr List URL", + connectedStatus: listStatus, + setupSteps: ( + <> +
  35. + Open Radarr, go to{" "} + + Settings > Import Lists + +
  36. +
  37. + Click + and + select{" "} + Custom Lists +
  38. +
  39. Paste the Radarr URL above into the List URL field
  40. +
  41. Set your preferred quality profile and root folder
  42. +
  43. + Titles on your Sofa watchlist will be automatically added for download + when Radarr polls this list (every 12 hours by default) +
  44. + + ), + docsUrl: "https://wiki.servarr.com/radarr/settings#import-lists", + }, +]; diff --git a/app/(pages)/settings/_components/integrations-section.tsx b/app/(pages)/settings/_components/integrations-section.tsx index 5a5df32..552f9e7 100644 --- a/app/(pages)/settings/_components/integrations-section.tsx +++ b/app/(pages)/settings/_components/integrations-section.tsx @@ -3,12 +3,16 @@ import { IconWebhook } from "@tabler/icons-react"; import { useHydrateAtoms } from "jotai/utils"; import { connectionsAtom } from "@/lib/atoms/integrations"; -import { WebhookCard, type WebhookConnection } from "./webhook-card"; +import { + IntegrationCard, + type IntegrationConnection, +} from "./integration-card"; +import { INTEGRATION_CONFIGS } from "./integration-configs"; export function IntegrationsSection({ initialConnections, }: { - initialConnections: WebhookConnection[]; + initialConnections: IntegrationConnection[]; }) { useHydrateAtoms([[connectionsAtom, initialConnections]]); @@ -24,9 +28,9 @@ export function IntegrationsSection({
    - - - + {INTEGRATION_CONFIGS.map((config) => ( + + ))}
    ); diff --git a/app/(pages)/settings/_components/system-health-section.tsx b/app/(pages)/settings/_components/system-health-section.tsx index 33282b4..865ff13 100644 --- a/app/(pages)/settings/_components/system-health-section.tsx +++ b/app/(pages)/settings/_components/system-health-section.tsx @@ -369,6 +369,7 @@ function RefreshButton({ aria-label="Refresh system health" onClick={onRefresh} disabled={isValidating} + className="text-muted-foreground" /> } > @@ -601,7 +602,7 @@ function BackgroundJobsCard({ ) : ( )} diff --git a/app/(pages)/settings/_components/webhook-card.tsx b/app/(pages)/settings/_components/webhook-card.tsx deleted file mode 100644 index 821d3ad..0000000 --- a/app/(pages)/settings/_components/webhook-card.tsx +++ /dev/null @@ -1,383 +0,0 @@ -"use client"; - -import { - IconBook2, - IconCheck, - IconChevronDown, - IconCopy, - IconExternalLink, - IconInfoCircle, - IconRefresh, - IconTrash, -} from "@tabler/icons-react"; -import { formatDistanceToNow } from "date-fns"; -import { AnimatePresence, motion } from "motion/react"; -import { useState } from "react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardTitle, -} from "@/components/ui/card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@/components/ui/input-group"; -import { Label } from "@/components/ui/label"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useConnectionActions } from "@/lib/atoms/integrations"; -import { EmbyIcon, JellyfinIcon, PlexIcon } from "./icons"; - -export interface WebhookConnection { - id: string; - provider: "plex" | "jellyfin" | "emby"; - token: string; - enabled: boolean; - lastEventAt: string | null; - recentEvents: { - id: string; - eventType: string | null; - mediaType: string | null; - mediaTitle: string | null; - status: "success" | "ignored" | "error"; - receivedAt: string; - }[]; -} - -export function WebhookCard({ - provider, -}: { - provider: "plex" | "jellyfin" | "emby"; -}) { - const { connection, handleConnect, handleDelete, handleRegenerateToken } = - useConnectionActions(provider); - 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 isEmby = provider === "emby"; - const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin"; - const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon; - - const webhookUrl = - connection && typeof window !== "undefined" - ? `${window.location.origin}/api/webhooks/${connection.token}` - : null; - - async function onConnect() { - setConnecting(true); - try { - await handleConnect(); - } finally { - setConnecting(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 ${formatDistanceToNow(new Date(connection.lastEventAt), { addSuffix: true })}` - : "Ready — no events yet" - : "Not configured"} - -
    -
    - -
    -
    - - - - {isPlex && ( - - - - Requires an active{" "} - - Plex Pass - - {" "} - subscription. - - - )} - - {isEmby && ( - - - - Requires{" "} - - Emby Server 4.7.9+ - {" "} - and an active{" "} - - Emby Premiere - - {" "} - license. - - - )} - - {!connection ? ( - - ) : ( - - {webhookUrl && ( - -
    - - - - - - - } - > - {copied ? ( - - ) : ( - - )} - - Copy URL - - - -
    - -
    - - -
    -
    - )} -
    - )} - - - - - Setup instructions - - -
    -
      - {isPlex ? ( - <> -
    1. - Open Plex, go to{" "} - - Settings > Webhooks - - -
    2. -
    3. - Click{" "} - - Add Webhook - {" "} - and paste the URL above -
    4. - - ) : isEmby ? ( - <> -
    5. - Open Emby, go to{" "} - - Settings > Webhooks - -
    6. -
    7. Add a new webhook and paste the URL above
    8. -
    9. - Enable the{" "} - - Playback - {" "} - event category -
    10. - - ) : ( - <> -
    11. - Install the{" "} - - Webhook plugin - - {" "} - from Jellyfin's plugin catalog -
    12. -
    13. - Go to{" "} - - Dashboard > Plugins > Webhook - -
    14. -
    15. - Add a{" "} - - Generic Destination - {" "} - and paste the URL above -
    16. -
    17. - Enable the{" "} - - Playback Stop - {" "} - notification type -
    18. - - )} -
    19. - Sofa will automatically log movies and episodes when you - finish watching them -
    20. -
    -

    - - Need more help?{" "} - - Open docs{" "} - - -

    -
    -
    -
    -
    -
    -
    -
    - ); -} diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 32203cd..8919c44 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -9,7 +9,7 @@ import { TmdbLogo } from "@/components/tmdb-logo"; import { Card } from "@/components/ui/card"; import { getSession } from "@/lib/auth/session"; import { db } from "@/lib/db/client"; -import { webhookConnections, webhookEventLog } from "@/lib/db/schema"; +import { integrationEvents, integrations } from "@/lib/db/schema"; import { listBackups } from "@/lib/services/backup"; import { getSetting } from "@/lib/services/settings"; import { @@ -34,8 +34,8 @@ export default async function SettingsPage() { const connRows = db .select() - .from(webhookConnections) - .where(eq(webhookConnections.userId, session.user.id)) + .from(integrations) + .where(eq(integrations.userId, session.user.id)) .all(); const connIds = connRows.map((c) => c.id); @@ -43,14 +43,14 @@ export default async function SettingsPage() { // Fetch only the 10 most recent events per connection (index-optimized) const eventsByConn = new Map< string, - (typeof webhookEventLog.$inferSelect)[] + (typeof integrationEvents.$inferSelect)[] >(); for (const connId of connIds) { const events = db .select() - .from(webhookEventLog) - .where(eq(webhookEventLog.connectionId, connId)) - .orderBy(desc(webhookEventLog.receivedAt)) + .from(integrationEvents) + .where(eq(integrationEvents.integrationId, connId)) + .orderBy(desc(integrationEvents.receivedAt)) .limit(10) .all(); eventsByConn.set(connId, events); @@ -59,6 +59,7 @@ export default async function SettingsPage() { const connections = connRows.map((conn) => ({ id: conn.id, provider: conn.provider, + type: conn.type, token: conn.token, enabled: conn.enabled, lastEventAt: conn.lastEventAt?.toISOString() ?? null, diff --git a/app/api/explore/discover/route.ts b/app/api/explore/discover/route.ts index f80fff3..47ee295 100644 --- a/app/api/explore/discover/route.ts +++ b/app/api/explore/discover/route.ts @@ -1,12 +1,23 @@ import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { auth } from "@/lib/auth/server"; import { isTmdbConfigured } from "@/lib/config"; import { discover } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; -const SORT_BY_PATTERN = /^[a-z_]+\.(asc|desc)$/; -const MAX_PAGE = 500; +const querySchema = z.object({ + type: z.enum(["movie", "tv"]).default("movie"), + sort_by: z + .string() + .regex(/^[a-z_]+\.(asc|desc)$/) + .default("popularity.desc"), + genre: z + .string() + .regex(/^\d+(,\d+)*$/) + .optional(), + page: z.coerce.number().int().min(1).max(500).default(1), +}); export async function GET(req: NextRequest) { const session = await auth.api.getSession({ @@ -26,40 +37,19 @@ export async function GET(req: NextRequest) { ); } - const { searchParams } = req.nextUrl; - const rawType = searchParams.get("type"); - if (rawType && rawType !== "movie" && rawType !== "tv") { + const raw = Object.fromEntries(req.nextUrl.searchParams); + const result = querySchema.safeParse(raw); + if (!result.success) { return NextResponse.json( - { error: "type must be movie or tv" }, - { status: 400 }, - ); - } - const type = rawType === "tv" ? "tv" : "movie"; - const genre = searchParams.get("genre"); - const sortBy = searchParams.get("sort_by") || "popularity.desc"; - const pageRaw = searchParams.get("page") || "1"; - const page = Number.parseInt(pageRaw, 10); - - if (!Number.isInteger(page) || page < 1 || page > MAX_PAGE) { - return NextResponse.json( - { error: `page must be an integer between 1 and ${MAX_PAGE}` }, + { error: result.error.issues[0].message }, { status: 400 }, ); } - if (!SORT_BY_PATTERN.test(sortBy)) { - return NextResponse.json( - { error: "Invalid sort_by value" }, - { status: 400 }, - ); - } - - if (genre && !/^\d+(,\d+)*$/.test(genre)) { - return NextResponse.json({ error: "Invalid genre value" }, { status: 400 }); - } + const { type, sort_by, genre, page } = result.data; const params: Record = { - sort_by: sortBy, + sort_by, "vote_count.gte": "50", }; if (genre) { diff --git a/app/api/images/[...path]/route.ts b/app/api/images/[...path]/route.ts index 76845a9..cf10726 100644 --- a/app/api/images/[...path]/route.ts +++ b/app/api/images/[...path]/route.ts @@ -1,12 +1,12 @@ import path from "node:path"; import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { fetchAndMaybeCache, - type ImageCategory, imageCacheEnabled, } from "@/lib/services/image-cache"; -const VALID_CATEGORIES = new Set([ +const categorySchema = z.enum([ "posters", "backdrops", "stills", @@ -33,11 +33,13 @@ export async function GET( return NextResponse.json({ error: "Invalid path" }, { status: 400 }); } - const [category, rawFilename] = segments.path; + const [rawCategory, rawFilename] = segments.path; - if (!VALID_CATEGORIES.has(category as ImageCategory)) { + const catResult = categorySchema.safeParse(rawCategory); + if (!catResult.success) { return NextResponse.json({ error: "Invalid category" }, { status: 400 }); } + const category = catResult.data; // Sanitize filename — only allow basename to prevent path traversal const filename = path.basename(rawFilename); @@ -46,7 +48,7 @@ export async function GET( } const tmdbPath = `/${filename}`; - const result = await fetchAndMaybeCache(tmdbPath, category as ImageCategory); + const result = await fetchAndMaybeCache(tmdbPath, category); if (!result) { return NextResponse.json({ error: "Not found" }, { status: 404 }); diff --git a/app/api/lists/[token]/route.ts b/app/api/lists/[token]/route.ts new file mode 100644 index 0000000..831ecf1 --- /dev/null +++ b/app/api/lists/[token]/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + getRadarrList, + getSonarrList, + parseStatusParam, + resolveListToken, +} from "@/lib/services/lists"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const result = resolveListToken(token); + if (!result) { + return NextResponse.json([]); + } + + const statuses = parseStatusParam(req.nextUrl.searchParams.get("status")); + + if (result.provider === "sonarr") { + return NextResponse.json(await getSonarrList(result.userId, statuses)); + } + return NextResponse.json(getRadarrList(result.userId, statuses)); +} diff --git a/app/api/titles/import/route.ts b/app/api/titles/import/route.ts index b04e197..0ab5ea8 100644 --- a/app/api/titles/import/route.ts +++ b/app/api/titles/import/route.ts @@ -1,9 +1,15 @@ import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { auth } from "@/lib/auth/server"; import { importTitle } from "@/lib/services/metadata"; +const bodySchema = z.object({ + tmdbId: z.coerce.number().int().positive(), + type: z.enum(["movie", "tv"]), +}); + export async function POST(req: NextRequest) { const session = await auth.api.getSession({ headers: await headers(), @@ -12,25 +18,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const parsed = body as { tmdbId?: unknown; type?: unknown }; - const type = parsed.type; - const tmdbId = - typeof parsed.tmdbId === "number" - ? parsed.tmdbId - : Number.parseInt(String(parsed.tmdbId), 10); - - if ( - !Number.isInteger(tmdbId) || - tmdbId < 1 || - (type !== "movie" && type !== "tv") - ) { + const result = bodySchema.safeParse(await req.json().catch(() => null)); + if (!result.success) { return NextResponse.json( { error: "tmdbId (positive integer) and type (movie|tv) are required" }, { status: 400 }, @@ -38,7 +27,7 @@ export async function POST(req: NextRequest) { } try { - const title = await importTitle(tmdbId, type); + const title = await importTitle(result.data.tmdbId, result.data.type); return NextResponse.json(title); } catch { return NextResponse.json( diff --git a/app/api/titles/resolve/route.ts b/app/api/titles/resolve/route.ts index 2a246a3..5d51e9a 100644 --- a/app/api/titles/resolve/route.ts +++ b/app/api/titles/resolve/route.ts @@ -1,9 +1,15 @@ import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { auth } from "@/lib/auth/server"; import { importTitle } from "@/lib/services/metadata"; +const bodySchema = z.object({ + tmdbId: z.coerce.number().int().positive(), + type: z.enum(["movie", "tv"]), +}); + export async function POST(req: NextRequest) { const session = await auth.api.getSession({ headers: await headers(), @@ -12,25 +18,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const parsed = body as { tmdbId?: unknown; type?: unknown }; - const type = parsed.type; - const tmdbId = - typeof parsed.tmdbId === "number" - ? parsed.tmdbId - : Number.parseInt(String(parsed.tmdbId), 10); - - if ( - !Number.isInteger(tmdbId) || - tmdbId < 1 || - (type !== "movie" && type !== "tv") - ) { + const result = bodySchema.safeParse(await req.json().catch(() => null)); + if (!result.success) { return NextResponse.json( { error: "tmdbId (positive integer) and type (movie|tv) are required" }, { status: 400 }, @@ -38,7 +27,7 @@ export async function POST(req: NextRequest) { } try { - const title = await importTitle(tmdbId, type); + const title = await importTitle(result.data.tmdbId, result.data.type); return NextResponse.json({ id: title?.id }); } catch { return NextResponse.json( diff --git a/app/api/webhooks/[token]/route.ts b/app/api/webhooks/[token]/route.ts index f4dca9f..6d1e03d 100644 --- a/app/api/webhooks/[token]/route.ts +++ b/app/api/webhooks/[token]/route.ts @@ -2,7 +2,7 @@ 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 { integrations } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; import type { WebhookEvent } from "@/lib/services/webhooks"; import { @@ -23,8 +23,8 @@ export async function POST( // Look up connection by token — this IS the auth const connection = db .select() - .from(webhookConnections) - .where(eq(webhookConnections.token, token)) + .from(integrations) + .where(eq(integrations.token, token)) .get(); if (!connection || !connection.enabled) { @@ -32,12 +32,19 @@ export async function POST( return NextResponse.json({ ok: true }); } + // Only webhook-type integrations are handled here + if (connection.type !== "webhook") { + return NextResponse.json({ ok: true }); + } + + const provider = connection.provider as "plex" | "jellyfin" | "emby"; + try { let event: WebhookEvent | null; - if (connection.provider === "plex") { + if (provider === "plex") { const formData = await req.formData(); event = parsePlexPayload(formData); - } else if (connection.provider === "emby") { + } else if (provider === "emby") { const body = await req.json(); event = parseEmbyPayload(body); } else { @@ -50,12 +57,7 @@ export async function POST( return NextResponse.json({ ok: true }); } - await processWebhook( - connection.id, - connection.userId, - connection.provider, - event, - ); + await processWebhook(connection.id, connection.userId, provider, event); } catch (err) { // Swallow errors — never return non-200 to media servers log.debug("Webhook processing failed:", err); diff --git a/drizzle/20260306195724_zippy_gladiator/migration.sql b/drizzle/20260306195724_zippy_gladiator/migration.sql new file mode 100644 index 0000000..233840d --- /dev/null +++ b/drizzle/20260306195724_zippy_gladiator/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE `integrationEvents` ( + `id` text PRIMARY KEY, + `integrationId` text NOT NULL, + `eventType` text, + `mediaType` text, + `mediaTitle` text, + `status` text NOT NULL, + `errorMessage` text, + `receivedAt` integer NOT NULL, + CONSTRAINT `fk_integrationEvents_integrationId_integrations_id_fk` FOREIGN KEY (`integrationId`) REFERENCES `integrations`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `integrations` ( + `id` text PRIMARY KEY, + `userId` text NOT NULL, + `provider` text NOT NULL, + `type` text NOT NULL, + `token` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `createdAt` integer NOT NULL, + `lastEventAt` integer, + CONSTRAINT `fk_integrations_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +ALTER TABLE `titles` ADD `tvdbId` integer;--> statement-breakpoint +DROP INDEX IF EXISTS `webhookConnections_userId_provider`;--> statement-breakpoint +DROP INDEX IF EXISTS `webhookConnections_token`;--> statement-breakpoint +DROP INDEX IF EXISTS `webhookEventLog_connectionId_receivedAt`;--> statement-breakpoint +CREATE INDEX `integrationEvents_integrationId_receivedAt` ON `integrationEvents` (`integrationId`,`receivedAt`);--> statement-breakpoint +CREATE UNIQUE INDEX `integrations_userId_provider` ON `integrations` (`userId`,`provider`);--> statement-breakpoint +CREATE UNIQUE INDEX `integrations_token` ON `integrations` (`token`);--> statement-breakpoint +DROP TABLE `webhookConnections`;--> statement-breakpoint +DROP TABLE `webhookEventLog`; \ No newline at end of file diff --git a/drizzle/20260306195724_zippy_gladiator/snapshot.json b/drizzle/20260306195724_zippy_gladiator/snapshot.json new file mode 100644 index 0000000..a2aeccf --- /dev/null +++ b/drizzle/20260306195724_zippy_gladiator/snapshot.json @@ -0,0 +1,2606 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f3d5f958-f483-486b-aebd-d7ad6837ea93", + "prevIds": [ + "bb5f768e-3a10-451a-9b73-4b7f19dc13ee" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "genres", + "entityType": "tables" + }, + { + "name": "integrationEvents", + "entityType": "tables" + }, + { + "name": "integrations", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleGenres", + "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" + }, + { + "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": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "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": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integrationId", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genreId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "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": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tvdbId", + "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": "contentRating", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "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" + }, + { + "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": [ + "integrationId" + ], + "tableTo": "integrations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrationEvents_integrationId_integrations_id_fk", + "entityType": "fks", + "table": "integrationEvents" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrations_userId_user_id_fk", + "entityType": "fks", + "table": "integrations" + }, + { + "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_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "genreId" + ], + "tableTo": "genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_genreId_genres_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "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": [ + "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": "genres_pk", + "table": "genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrationEvents_pk", + "table": "integrationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrations_pk", + "table": "integrations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "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": "titleCast_pk", + "table": "titleCast", + "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": [ + { + "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": "integrationId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "integrationEvents_integrationId_receivedAt", + "entityType": "indexes", + "table": "integrationEvents" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_userId_provider", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_token", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "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": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleGenres_titleId_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleGenres_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "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": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_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": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_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": [ + "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 9e87950..0e965bf 100644 --- a/lib/actions/settings.ts +++ b/lib/actions/settings.ts @@ -2,10 +2,11 @@ import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; +import { z } from "zod"; import { auth } from "@/lib/auth/server"; import { type BackupFrequency, rescheduleBackup } from "@/lib/cron"; import { db } from "@/lib/db/client"; -import { webhookConnections } from "@/lib/db/schema"; +import { integrations } from "@/lib/db/schema"; import { type BackupInfo, createBackup, @@ -14,6 +15,14 @@ import { } from "@/lib/services/backup"; import { getSetting, setSetting } from "@/lib/services/settings"; +const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]); + +const LIST_PROVIDERS = new Set(["sonarr", "radarr"]); + +function integrationTypeFor(provider: string): "webhook" | "list" { + return LIST_PROVIDERS.has(provider) ? "list" : "webhook"; +} + async function getSession() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error("Unauthorized"); @@ -26,124 +35,106 @@ async function getAdminSession() { return session; } -// --- Webhook actions --- +function generateToken() { + return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString( + "hex", + ); +} -export async function saveWebhookConnection( - provider: "plex" | "jellyfin" | "emby", - enabled?: boolean, -) { +// --- Integration actions --- + +export async function saveIntegration(provider: string, enabled?: boolean) { const session = await getSession(); - - if (!["plex", "jellyfin", "emby"].includes(provider)) { - throw new Error("Invalid provider"); - } + const parsed = providerSchema.parse(provider); const existing = db .select() - .from(webhookConnections) + .from(integrations) .where( and( - eq(webhookConnections.userId, session.user.id), - eq(webhookConnections.provider, provider), + eq(integrations.userId, session.user.id), + eq(integrations.provider, parsed), ), ) .get(); if (existing) { - const connection = db - .update(webhookConnections) + const row = db + .update(integrations) .set({ enabled: typeof enabled === "boolean" ? enabled : existing.enabled, }) - .where(eq(webhookConnections.id, existing.id)) + .where(eq(integrations.id, existing.id)) .returning() .get(); return { - ...connection, - lastEventAt: connection.lastEventAt?.toISOString() ?? null, - createdAt: connection.createdAt.toISOString(), + ...row, + lastEventAt: row.lastEventAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), }; } - const token = Buffer.from( - crypto.getRandomValues(new Uint8Array(32)), - ).toString("hex"); - const now = new Date(); - - const connection = db - .insert(webhookConnections) + const row = db + .insert(integrations) .values({ userId: session.user.id, - provider, - token, + provider: parsed, + type: integrationTypeFor(parsed), + token: generateToken(), enabled: true, - createdAt: now, + createdAt: new Date(), }) .returning() .get(); return { - ...connection, - lastEventAt: connection.lastEventAt?.toISOString() ?? null, - createdAt: connection.createdAt.toISOString(), + ...row, + lastEventAt: row.lastEventAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), }; } -export async function deleteWebhookConnection( - provider: "plex" | "jellyfin" | "emby", -) { +export async function deleteIntegration(provider: string) { const session = await getSession(); + const parsed = providerSchema.parse(provider); - if (!["plex", "jellyfin", "emby"].includes(provider)) { - throw new Error("Invalid provider"); - } - - db.delete(webhookConnections) + db.delete(integrations) .where( and( - eq(webhookConnections.userId, session.user.id), - eq(webhookConnections.provider, provider), + eq(integrations.userId, session.user.id), + eq(integrations.provider, parsed), ), ) .run(); } -export async function regenerateWebhookToken( - provider: "plex" | "jellyfin" | "emby", -) { +export async function regenerateIntegrationToken(provider: string) { const session = await getSession(); + const parsed = providerSchema.parse(provider); - if (!["plex", "jellyfin", "emby"].includes(provider)) { - throw new Error("Invalid provider"); - } - - const newToken = Buffer.from( - crypto.getRandomValues(new Uint8Array(32)), - ).toString("hex"); - - const connection = db - .update(webhookConnections) - .set({ token: newToken }) + const row = db + .update(integrations) + .set({ token: generateToken() }) .where( and( - eq(webhookConnections.userId, session.user.id), - eq(webhookConnections.provider, provider), + eq(integrations.userId, session.user.id), + eq(integrations.provider, parsed), ), ) .returning() .get(); - if (!connection) { - throw new Error("Connection not found"); - } + if (!row) throw new Error("Integration not found"); return { - ...connection, - lastEventAt: connection.lastEventAt?.toISOString() ?? null, - createdAt: connection.createdAt.toISOString(), + ...row, + lastEventAt: row.lastEventAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), }; } +// --- Admin actions --- + export async function toggleRegistration(open: boolean) { await getAdminSession(); setSetting("registrationOpen", String(open)); @@ -189,14 +180,33 @@ export async function getScheduledBackupSettings(): Promise<{ }; } +const maxBackupsSchema = z + .number() + .int() + .refine((n) => n === 0 || (n >= 1 && n <= 30), { + message: "Max backups must be between 1 and 30, or 0 for unlimited", + }); + export async function setMaxBackupsAction(max: number): Promise { await getAdminSession(); - if (max < 0 || (max > 30 && max !== 0)) - throw new Error("Max backups must be between 1 and 30, or 0 for unlimited"); + maxBackupsSchema.parse(max); setSetting("maxBackupRetention", String(max)); } -const VALID_FREQUENCIES: BackupFrequency[] = ["6h", "12h", "1d", "7d"]; +const backupScheduleSchema = z.object({ + frequency: z.enum(["6h", "12h", "1d", "7d"]), + time: z + .string() + .regex(/^\d{2}:\d{2}$/, "Invalid time format") + .refine( + (t) => { + const [h, m] = t.split(":").map(Number); + return h >= 0 && h <= 23 && m >= 0 && m <= 59; + }, + { message: "Invalid time value" }, + ), + dayOfWeek: z.number().int().min(0).max(6).default(0), +}); export async function setBackupScheduleAction( frequency: BackupFrequency, @@ -204,14 +214,9 @@ export async function setBackupScheduleAction( dayOfWeek = 0, ): Promise { await getAdminSession(); - if (!VALID_FREQUENCIES.includes(frequency)) - throw new Error("Invalid frequency"); - if (!/^\d{2}:\d{2}$/.test(time)) throw new Error("Invalid time format"); - const [h, m] = time.split(":").map(Number); - if (h < 0 || h > 23 || m < 0 || m > 59) throw new Error("Invalid time value"); - if (dayOfWeek < 0 || dayOfWeek > 6) throw new Error("Invalid day of week"); - setSetting("backupScheduleFrequency", frequency); - setSetting("backupScheduleTime", time); - setSetting("backupScheduleDow", String(dayOfWeek)); + const parsed = backupScheduleSchema.parse({ frequency, time, dayOfWeek }); + setSetting("backupScheduleFrequency", parsed.frequency); + setSetting("backupScheduleTime", parsed.time); + setSetting("backupScheduleDow", String(parsed.dayOfWeek)); rescheduleBackup(); } diff --git a/lib/actions/titles.ts b/lib/actions/titles.ts index 17bed99..9f9a80d 100644 --- a/lib/actions/titles.ts +++ b/lib/actions/titles.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { headers } from "next/headers"; +import { z } from "zod"; import { auth } from "@/lib/auth/server"; import { db } from "@/lib/db/client"; import { episodes } from "@/lib/db/schema"; @@ -40,10 +41,11 @@ export async function markAllWatchedAction(titleId: string) { markAllEpisodesWatched(userId, titleId); } +const ratingSchema = z.number().int().min(0).max(5); + export async function updateTitleRating(titleId: string, ratingStars: number) { const userId = await getSessionUserId(); - if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating"); - rateTitleStars(userId, titleId, ratingStars); + rateTitleStars(userId, titleId, ratingSchema.parse(ratingStars)); } export async function watchMovie(titleId: string) { diff --git a/lib/atoms/integrations.ts b/lib/atoms/integrations.ts index 8197baa..aa6d670 100644 --- a/lib/atoms/integrations.ts +++ b/lib/atoms/integrations.ts @@ -1,31 +1,22 @@ import { atom, useAtom } from "jotai"; import { useCallback } from "react"; import { toast } from "sonner"; -import type { WebhookConnection } from "@/app/(pages)/settings/_components/webhook-card"; +import type { IntegrationConnection } from "@/app/(pages)/settings/_components/integration-card"; import { - deleteWebhookConnection, - regenerateWebhookToken, - saveWebhookConnection, + deleteIntegration, + regenerateIntegrationToken, + saveIntegration, } from "@/lib/actions/settings"; -export const connectionsAtom = atom([]); +export const connectionsAtom = atom([]); -function providerLabel(provider: "plex" | "jellyfin" | "emby") { - return provider === "plex" - ? "Plex" - : provider === "emby" - ? "Emby" - : "Jellyfin"; -} - -export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") { +export function useConnectionActions(provider: string, label: string) { const [connections, setConnections] = useAtom(connectionsAtom); - const label = providerLabel(provider); const connection = connections.find((c) => c.provider === provider) ?? null; const handleConnect = useCallback(async () => { try { - const result = await saveWebhookConnection(provider); + const result = await saveIntegration(provider); setConnections((prev) => [...prev, { ...result, recentEvents: [] }]); toast.success(`${label} connected`); } catch { @@ -37,7 +28,7 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") { const previous = connections; setConnections((prev) => prev.filter((c) => c.provider !== provider)); try { - await deleteWebhookConnection(provider); + await deleteIntegration(provider); toast.success(`${label} disconnected`); } catch { setConnections(previous); @@ -47,13 +38,13 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") { const handleRegenerateToken = useCallback(async () => { try { - const result = await regenerateWebhookToken(provider); + const result = await regenerateIntegrationToken(provider); setConnections((prev) => prev.map((c) => c.provider === provider ? { ...c, token: result.token } : c, ), ); - toast.success(`${label} webhook URL regenerated`); + toast.success(`${label} URL regenerated`); } catch { toast.error(`Failed to regenerate ${label} URL`); } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 67c0e00..056eca2 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -102,6 +102,7 @@ export const titles = sqliteTable( { id: uuidPk(), tmdbId: int("tmdbId").notNull(), + tvdbId: int("tvdbId"), type: text("type", { enum: ["movie", "tv"] }).notNull(), title: text("title").notNull(), originalTitle: text("originalTitle"), @@ -374,39 +375,38 @@ export const titleCast = sqliteTable( ], ); -// ─── Webhook Connections ───────────────────────────────────────────── +// ─── Integrations ─────────────────────────────────────────────────── -export const webhookConnections = sqliteTable( - "webhookConnections", +export const integrations = sqliteTable( + "integrations", { id: uuidPk(), userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade" }), - provider: text("provider", { - enum: ["plex", "jellyfin", "emby"], - }).notNull(), + provider: text("provider").notNull(), + type: text("type", { enum: ["webhook", "list"] }).notNull(), token: text("token").notNull().unique(), 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( + uniqueIndex("integrations_userId_provider").on( table.userId, table.provider, ), - uniqueIndex("webhookConnections_token").on(table.token), + uniqueIndex("integrations_token").on(table.token), ], ); -export const webhookEventLog = sqliteTable( - "webhookEventLog", +export const integrationEvents = sqliteTable( + "integrationEvents", { id: uuidPk(), - connectionId: text("connectionId") + integrationId: text("integrationId") .notNull() - .references(() => webhookConnections.id, { onDelete: "cascade" }), + .references(() => integrations.id, { onDelete: "cascade" }), eventType: text("eventType"), mediaType: text("mediaType"), mediaTitle: text("mediaTitle"), @@ -417,8 +417,8 @@ export const webhookEventLog = sqliteTable( receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(), }, (table) => [ - index("webhookEventLog_connectionId_receivedAt").on( - table.connectionId, + index("integrationEvents_integrationId_receivedAt").on( + table.integrationId, table.receivedAt, ), ], diff --git a/lib/services/backup.ts b/lib/services/backup.ts index 015d2c6..e713fe7 100644 --- a/lib/services/backup.ts +++ b/lib/services/backup.ts @@ -47,8 +47,8 @@ const REQUIRED_TABLES = [ "userRatings", "userTitleStatus", "verification", - "webhookConnections", - "webhookEventLog", + "integrations", + "integrationEvents", ] as const; let backupOpQueue: Promise = Promise.resolve(); diff --git a/lib/services/lists.test.ts b/lib/services/lists.test.ts new file mode 100644 index 0000000..551b040 --- /dev/null +++ b/lib/services/lists.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { + clearAllTables, + insertIntegration, + insertStatus, + insertTitle, + insertUser, +} from "@/lib/test-utils"; +import { + getRadarrList, + getSonarrList, + parseStatusParam, + resolveListToken, +} from "./lists"; + +// Mock getTvExternalIds for lazy resolution tests +const mockGetTvExternalIds = mock(() => + Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }), +); +mock.module("@/lib/tmdb/client", () => ({ + getTvExternalIds: mockGetTvExternalIds, +})); + +beforeEach(() => { + clearAllTables(); + mockGetTvExternalIds.mockClear(); +}); + +describe("resolveListToken", () => { + test("returns userId and provider for valid sonarr token", () => { + insertUser("user-1"); + insertIntegration("user-1", "sonarr", "sonarr-token"); + expect(resolveListToken("sonarr-token")).toEqual({ + userId: "user-1", + provider: "sonarr", + }); + }); + + test("returns userId and provider for valid radarr token", () => { + insertUser("user-1"); + insertIntegration("user-1", "radarr", "radarr-token"); + expect(resolveListToken("radarr-token")).toEqual({ + userId: "user-1", + provider: "radarr", + }); + }); + + test("returns null for invalid token", () => { + expect(resolveListToken("nonexistent")).toBeNull(); + }); + + test("returns null for webhook-type token", () => { + insertUser("user-1"); + insertIntegration("user-1", "plex", "plex-token"); + expect(resolveListToken("plex-token")).toBeNull(); + }); +}); + +describe("parseStatusParam", () => { + test("defaults to watchlist when null", () => { + expect(parseStatusParam(null)).toEqual(["watchlist"]); + }); + + test("parses comma-separated statuses", () => { + expect(parseStatusParam("watchlist,in_progress")).toEqual([ + "watchlist", + "in_progress", + ]); + }); + + test("filters invalid statuses", () => { + expect(parseStatusParam("watchlist,invalid,completed")).toEqual([ + "watchlist", + "completed", + ]); + }); + + test("defaults to watchlist when all invalid", () => { + expect(parseStatusParam("foo,bar")).toEqual(["watchlist"]); + }); +}); + +describe("getRadarrList", () => { + test("returns movie TMDB IDs on watchlist", () => { + insertUser("user-1"); + insertTitle({ id: "m1", tmdbId: 100, type: "movie" }); + insertTitle({ id: "m2", tmdbId: 200, type: "movie" }); + insertStatus("user-1", "m1", "watchlist"); + insertStatus("user-1", "m2", "watchlist"); + + const list = getRadarrList("user-1"); + expect(list).toEqual(expect.arrayContaining([{ Id: 100 }, { Id: 200 }])); + expect(list).toHaveLength(2); + }); + + test("excludes TV shows", () => { + insertUser("user-1"); + insertTitle({ id: "m1", tmdbId: 100, type: "movie" }); + insertTitle({ id: "tv1", tmdbId: 200, type: "tv" }); + insertStatus("user-1", "m1", "watchlist"); + insertStatus("user-1", "tv1", "watchlist"); + + const list = getRadarrList("user-1"); + expect(list).toEqual([{ Id: 100 }]); + }); + + test("filters by status", () => { + insertUser("user-1"); + insertTitle({ id: "m1", tmdbId: 100, type: "movie" }); + insertTitle({ id: "m2", tmdbId: 200, type: "movie" }); + insertStatus("user-1", "m1", "watchlist"); + insertStatus("user-1", "m2", "completed"); + + expect(getRadarrList("user-1", ["watchlist"])).toEqual([{ Id: 100 }]); + expect(getRadarrList("user-1", ["completed"])).toEqual([{ Id: 200 }]); + expect(getRadarrList("user-1", ["watchlist", "completed"])).toHaveLength(2); + }); + + test("returns empty array for user with no movies", () => { + insertUser("user-1"); + expect(getRadarrList("user-1")).toEqual([]); + }); +}); + +describe("getSonarrList", () => { + test("returns TV shows with TVDB IDs", async () => { + insertUser("user-1"); + insertTitle({ + id: "tv1", + tmdbId: 300, + tvdbId: 12345, + type: "tv", + title: "Show A", + }); + insertStatus("user-1", "tv1", "watchlist"); + + const list = await getSonarrList("user-1"); + expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]); + }); + + test("excludes movies", async () => { + insertUser("user-1"); + insertTitle({ id: "m1", tmdbId: 100, type: "movie" }); + insertTitle({ + id: "tv1", + tmdbId: 300, + tvdbId: 12345, + type: "tv", + title: "Show A", + }); + insertStatus("user-1", "m1", "watchlist"); + insertStatus("user-1", "tv1", "watchlist"); + + const list = await getSonarrList("user-1"); + expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]); + }); + + test("lazily resolves missing TVDB ID", async () => { + insertUser("user-1"); + insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show B" }); + insertStatus("user-1", "tv1", "watchlist"); + + mockGetTvExternalIds.mockResolvedValueOnce({ + tvdb_id: 55555, + imdb_id: "tt1234567", + }); + + const list = await getSonarrList("user-1"); + expect(list).toEqual([{ TvdbId: 55555, Title: "Show B" }]); + expect(mockGetTvExternalIds).toHaveBeenCalledWith(300); + }); + + test("skips shows where TVDB ID cannot be resolved", async () => { + insertUser("user-1"); + insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show C" }); + insertStatus("user-1", "tv1", "watchlist"); + + mockGetTvExternalIds.mockResolvedValueOnce({ + tvdb_id: null, + imdb_id: null, + }); + + const list = await getSonarrList("user-1"); + expect(list).toEqual([]); + }); + + test("filters by status", async () => { + insertUser("user-1"); + insertTitle({ + id: "tv1", + tmdbId: 300, + tvdbId: 111, + type: "tv", + title: "Show A", + }); + insertTitle({ + id: "tv2", + tmdbId: 400, + tvdbId: 222, + type: "tv", + title: "Show B", + }); + insertStatus("user-1", "tv1", "watchlist"); + insertStatus("user-1", "tv2", "completed"); + + const watchlist = await getSonarrList("user-1", ["watchlist"]); + expect(watchlist).toEqual([{ TvdbId: 111, Title: "Show A" }]); + + const all = await getSonarrList("user-1", ["watchlist", "completed"]); + expect(all).toHaveLength(2); + }); +}); diff --git a/lib/services/lists.ts b/lib/services/lists.ts new file mode 100644 index 0000000..bed9d7e --- /dev/null +++ b/lib/services/lists.ts @@ -0,0 +1,114 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/lib/db/client"; +import { integrations, titles, userTitleStatus } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; +import { getTvExternalIds } from "@/lib/tmdb/client"; + +const log = createLogger("lists"); + +/** Look up a list integration token and return the userId + provider, or null. */ +export function resolveListToken( + token: string, +): { userId: string; provider: "sonarr" | "radarr" } | null { + const row = db + .select({ + userId: integrations.userId, + provider: integrations.provider, + }) + .from(integrations) + .where( + and( + eq(integrations.token, token), + eq(integrations.type, "list"), + eq(integrations.enabled, true), + ), + ) + .get(); + if (!row) return null; + return { + userId: row.userId, + provider: row.provider as "sonarr" | "radarr", + }; +} + +const statusSchema = z.enum(["watchlist", "in_progress", "completed"]); +type Status = z.infer; + +/** Parse a comma-separated status query param into validated statuses. */ +export function parseStatusParam(param: string | null): Status[] { + if (!param) return ["watchlist"]; + const parsed = param + .split(",") + .filter((s) => statusSchema.safeParse(s).success) as Status[]; + return parsed.length > 0 ? parsed : ["watchlist"]; +} + +/** Radarr custom list format: `[{ Id: tmdbId }]` for movies. */ +export function getRadarrList( + userId: string, + statuses: Status[] = ["watchlist"], +): { Id: number }[] { + const rows = db + .select({ tmdbId: titles.tmdbId }) + .from(userTitleStatus) + .innerJoin(titles, eq(userTitleStatus.titleId, titles.id)) + .where( + and( + eq(userTitleStatus.userId, userId), + eq(titles.type, "movie"), + inArray(userTitleStatus.status, statuses), + ), + ) + .all(); + return rows.map((r) => ({ Id: r.tmdbId })); +} + +/** Sonarr custom list format: `[{ TvdbId, Title }]` for TV shows. + * Lazily resolves missing TVDB IDs via TMDB API and caches them. */ +export async function getSonarrList( + userId: string, + statuses: Status[] = ["watchlist"], +): Promise<{ TvdbId: number; Title: string }[]> { + const rows = db + .select({ + id: titles.id, + tmdbId: titles.tmdbId, + tvdbId: titles.tvdbId, + title: titles.title, + }) + .from(userTitleStatus) + .innerJoin(titles, eq(userTitleStatus.titleId, titles.id)) + .where( + and( + eq(userTitleStatus.userId, userId), + eq(titles.type, "tv"), + inArray(userTitleStatus.status, statuses), + ), + ) + .all(); + + const result: { TvdbId: number; Title: string }[] = []; + + for (const row of rows) { + let tvdbId = row.tvdbId; + + if (tvdbId == null) { + try { + const externalIds = await getTvExternalIds(row.tmdbId); + tvdbId = externalIds.tvdb_id; + if (tvdbId != null) { + db.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run(); + } + } catch (err) { + log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err); + } + } + + if (tvdbId != null) { + result.push({ TvdbId: tvdbId, Title: row.title }); + } + } + + return result; +} diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 673deb5..e99f8a9 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -149,6 +149,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") { backdropPath: show.backdrop_path, status: show.status, contentRating: extractTvContentRating(show), + tvdbId: show.external_ids?.tvdb_id ?? null, lastFetchedAt: new Date(), }) .where(eq(titles.id, existing.id)) @@ -238,6 +239,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") { const row = insertTitleOrGet( { tmdbId: show.id, + tvdbId: show.external_ids?.tvdb_id ?? null, type: "tv", title: show.name, originalTitle: show.original_name, @@ -326,6 +328,7 @@ export async function refreshTitle(titleId: string) { voteCount: show.vote_count, status: show.status, contentRating: extractTvContentRating(show), + tvdbId: show.external_ids?.tvdb_id ?? null, lastFetchedAt: now, }) .where(eq(titles.id, titleId)) diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts index 7196608..c221222 100644 --- a/lib/services/webhooks.ts +++ b/lib/services/webhooks.ts @@ -2,11 +2,11 @@ import { and, eq, gte } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { episodes, + integrationEvents, + integrations, seasons, userEpisodeWatches, userMovieWatches, - webhookConnections, - webhookEventLog, } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; import { findByExternalId, searchTv } from "@/lib/tmdb/client"; @@ -283,9 +283,9 @@ function logEvent( status: "success" | "ignored" | "error", errorMessage?: string, ) { - db.insert(webhookEventLog) + db.insert(integrationEvents) .values({ - connectionId, + integrationId: connectionId, eventType: event?.provider === "plex" ? "media.scrobble" @@ -300,9 +300,9 @@ function logEvent( }) .run(); - db.update(webhookConnections) + db.update(integrations) .set({ lastEventAt: new Date() }) - .where(eq(webhookConnections.id, connectionId)) + .where(eq(integrations.id, connectionId)) .run(); } diff --git a/lib/test-utils.ts b/lib/test-utils.ts index e1d03ca..151fb25 100644 --- a/lib/test-utils.ts +++ b/lib/test-utils.ts @@ -14,6 +14,7 @@ const { userRatings, availabilityOffers, titleRecommendations, + integrations, } = schema; export const testClient = new Database(":memory:"); @@ -58,6 +59,7 @@ export function insertTitle( overrides: { id?: string; tmdbId?: number; + tvdbId?: number; type?: "movie" | "tv"; title?: string; } = {}, @@ -68,6 +70,7 @@ export function insertTitle( .values({ id, tmdbId: overrides.tmdbId ?? 12345, + tvdbId: overrides.tvdbId, type: overrides.type ?? "movie", title: overrides.title ?? "Test Movie", }) @@ -180,6 +183,27 @@ export function insertAvailabilityOffer( .run(); } +export function insertIntegration( + userId: string, + provider: string, + token = "test-token", +) { + const type = + provider === "sonarr" || provider === "radarr" ? "list" : "webhook"; + return testDb + .insert(integrations) + .values({ + userId, + provider, + type, + token, + enabled: true, + createdAt: new Date(), + }) + .returning() + .get(); +} + export function insertRecommendation( titleId: string, recommendedTitleId: string, diff --git a/lib/tmdb/client.ts b/lib/tmdb/client.ts index 032fe11..57a525f 100644 --- a/lib/tmdb/client.ts +++ b/lib/tmdb/client.ts @@ -1,5 +1,6 @@ import { createLogger } from "@/lib/logger"; import type { + TmdbExternalIds, TmdbFindResult, TmdbGenreListResponse, TmdbMovieCreditsResponse, @@ -90,10 +91,14 @@ export async function getMovieDetails(tmdbId: number) { export async function getTvDetails(tmdbId: number) { return tmdbFetch(`/tv/${tmdbId}`, { - append_to_response: "content_ratings", + append_to_response: "content_ratings,external_ids", }); } +export async function getTvExternalIds(tmdbId: number) { + return tmdbFetch(`/tv/${tmdbId}/external_ids`); +} + export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) { return tmdbFetch(`/tv/${tmdbId}/season/${seasonNumber}`); } diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts index 39e53d4..f1cc520 100644 --- a/lib/tmdb/types.ts +++ b/lib/tmdb/types.ts @@ -61,9 +61,15 @@ export interface TmdbTvDetails { content_ratings?: { results: { iso_3166_1: string; rating: string }[]; }; + external_ids?: TmdbExternalIds; seasons: TmdbSeasonSummary[]; } +export interface TmdbExternalIds { + tvdb_id: number | null; + imdb_id: string | null; +} + export interface TmdbSeasonSummary { id: number; season_number: number;