Add Sonarr and Radarr list integrations

- New `lists` service fetches Sonarr/Radarr library via their REST APIs
  and auto-imports matching titles from TMDB into the user's watchlist
- `app/api/lists/[token]/route.ts` webhook endpoint triggers a list sync
- Unified `IntegrationCard` component replaces `WebhookCard`, handling
  both webhook-style (Plex/Jellyfin/Emby) and list-style (Sonarr/Radarr)
  integrations with per-type config forms
- Schema migration adds `sonarr` and `radarr` to the integration type
  enum and a `listConnections` table for list-based integrations
- 212 tests added for the lists service covering import, deduplication,
  and error handling
This commit is contained in:
2026-03-06 15:34:34 -05:00
parent 8a3717cea7
commit 49cea568a9
27 changed files with 3787 additions and 604 deletions
+41 -3
View File
@@ -19,6 +19,25 @@ export function PlexIcon(props: SVGProps<SVGSVGElement>) {
);
}
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Simple Icons by Simple Icons Collaborators - https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md */}
<path
fill="currentColor"
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
/>
</svg>
);
}
export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -38,7 +57,7 @@ export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
);
}
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
export function SonarrIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -48,10 +67,29 @@ export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
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 */}
<path
fill="currentColor"
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"
d="m7.338 16.322l.165.159l-2.491 2.495l.13.129l2.493-2.498l.164.159l1.531-1.59l-.461-.444zm.106-8.651l-.161.161L8.855 9.4l.452-.453l-1.572-1.568l-.162.162L5 4.976q-.064.065-.127.132ZM5 4.976l-.128.131c.043-.043.083-.088.128-.131m0-.001l-.129.13l.128-.131ZM16.631 16.24l-1.648-1.64l-.451.453l1.647 1.64l.161-.162l2.533 2.621l.007-.006l.053-.052q.035-.035.067-.073L16.469 16.4ZM19 19.025q-.032.038-.067.073zm-.127.127l.007-.006zm-2.397-11.69l.062.065zl-.163-.162l-1.549 1.575l.455.449l1.549-1.572l-.163-.16l2.544-2.476q-.062-.067-.126-.132Zm2.672-2.346l-.127-.132q.065.065.127.132m.024-.023l-.128-.131l-.022.021l.127.132zm-7.156 4.139a2.66 2.66 0 0 0-1.941.8a2.62 2.62 0 0 0-.795 1.745v.384a3 3 0 0 0 .037.325a2.6 2.6 0 0 0 .763 1.434a2.4 2.4 0 0 0 .342.292a2.76 2.76 0 0 0 3.2 0a2.4 2.4 0 0 0 .279-.233l.059-.059a2.76 2.76 0 0 0 0-3.888a2.65 2.65 0 0 0-1.944-.8m6.917 9.862l-.053.052q.029-.025.053-.052M5.823 4.238l-.008.007Zm-.822.736l-.002.002zm.307 14.333l-.02-.018ZM7.505 12.1a5.64 5.64 0 0 0-1.426-4.257c-.806-.806-1.92-1.916-1.923-1.919a9.3 9.3 0 0 0-2.024 5.35a.13.13 0 0 0-.018.064Q2.1 11.653 2.1 12c0 .219 0 .439.014.658a10 10 0 0 0 .132 1.169a9.3 9.3 0 0 0 2.038 4.4c.007-.007.9-.9 1.75-1.754A5.63 5.63 0 0 0 7.505 12.1m4.527 4.587c-1.806 0-3.036.167-4.358 1.49a432 432 0 0 0-1.694 1.7q.125.098.255.189a9.43 9.43 0 0 0 5.774 1.846a9.5 9.5 0 0 0 5.784-1.846c.1-.068.189-.139.282-.211l-1.6-1.6c-1.428-1.431-2.56-1.568-4.443-1.568m-6.113 3.142L5.9 19.81Zm-.31-.252l-.023-.021Zm6.423-11.986a5.86 5.86 0 0 0 4.441-1.562c.753-.753 1.744-1.74 1.762-1.758a9.52 9.52 0 0 0-6.226-2.18a9.56 9.56 0 0 0-6.186 2.147L7.683 6.1a5.8 5.8 0 0 0 4.349 1.491m6.99-2.607v-.001l-.009-.009l-.002-.002l.002.002Zm-1.183 3.037c-1.2 1.2-1.3 2.238-1.3 4.075a5.7 5.7 0 0 0 1.48 4.358c.879.879 1.712 1.708 1.734 1.73A9.55 9.55 0 0 0 21.9 12a9.6 9.6 0 0 0-2.429-6.531q.217.25.414.5zm1.525 10.616l-.022.023zM19.233 5.2l-.084-.089z"
/>
</svg>
);
}
export function RadarrIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
{...props}
>
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="m8.06 16.01l7.199-4.113l-7.052-3.966Zm-1.028 3.82a2.96 2.96 0 0 1-2.5.294A3.37 3.37 0 0 0 8.648 21.3l10.136-5.876a1.73 1.73 0 0 0 .294-2.645zM19.225 9.106L8.8 3.083C6.738 1.614 3.359 2.5 3.359 6.168l.147 11.605c0 1.175.882 1.763 2.057 1.616L5.416 5.433c0-1.322.735-1.469 1.616-.881l11.752 6.61a2.9 2.9 0 0 1 1.47 2.2a3.307 3.307 0 0 0-1.029-4.256"
/>
</svg>
);
@@ -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 <ol>). */
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 (
<Card>
<Collapsible open={cardOpen} onOpenChange={setCardOpen}>
<CardContent className={cardOpen ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4 text-primary" />
</div>
<div className="text-left">
<CardTitle>{config.label}</CardTitle>
<CardDescription>
{connection
? config.connectedStatus(connection.lastEventAt)
: "Not configured"}
</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
</CardContent>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent className="space-y-3 border-t border-border/30 pt-4">
{config.alert}
{!connection ? (
<Button
onClick={onConnect}
disabled={connecting}
size="lg"
className="w-full"
>
{connecting ? "Connecting\u2026" : `Connect ${config.label}`}
</Button>
) : (
<AnimatePresence>
{url && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div>
<Label
htmlFor={`${config.provider}-url`}
className="mb-1 text-muted-foreground"
>
{config.urlLabel}
</Label>
<InputGroup>
<InputGroupInput
id={`${config.provider}-url`}
readOnly
value={url}
className="font-mono text-[10px] text-muted-foreground"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRegenerateToken()}
>
<IconRefresh />
Regenerate URL
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete()}
>
<IconTrash />
Disconnect
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)}
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
<IconChevronDown
aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
/>
Setup instructions
</CollapsibleTrigger>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<div className="mt-2 rounded-lg bg-muted/30 border border-border/50 p-3 text-xs leading-relaxed text-muted-foreground">
<ol className="list-inside list-decimal space-y-1.5">
{config.setupSteps}
</ol>
{config.docsUrl && (
<p className="mt-2 -ml-0.5">
<IconBook2
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px] mr-1"
/>
Need more help?{" "}
<a
href={config.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Open docs{" "}
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</p>
)}
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
// ─── 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";
}
@@ -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 (
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary gap-0">
<IconInfoCircle aria-hidden={true} className="inline-block size-3.5" />
<AlertDescription className="text-foreground/80">
{children}
</AlertDescription>
</Alert>
);
}
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: (
<RequirementAlert>
Requires an active{" "}
<a
href="https://www.plex.tv/plex-pass/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Plex Pass
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
subscription.
</RequirementAlert>
),
setupSteps: (
<>
<li>
Open Plex, go to{" "}
<a
href="https://app.plex.tv/desktop/#!/settings/webhooks"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Settings &gt; Webhooks
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</li>
<li>
Click <span className="font-medium text-foreground">Add Webhook</span>{" "}
and paste the URL above
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
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: (
<>
<li>
Install the{" "}
<a
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Webhook plugin
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
from Jellyfin&apos;s plugin catalog
</li>
<li>
Go to{" "}
<span className="font-medium text-foreground">
Dashboard &gt; Plugins &gt; Webhook
</span>
</li>
<li>
Add a{" "}
<span className="font-medium text-foreground">
Generic Destination
</span>{" "}
and paste the URL above
</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">Playback Stop</span>{" "}
notification type
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
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: (
<RequirementAlert>
Requires{" "}
<span className="font-medium text-foreground">Emby Server 4.7.9+</span>{" "}
and an active{" "}
<a
href="https://emby.media/premiere.html"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Emby Premiere
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
license.
</RequirementAlert>
),
setupSteps: (
<>
<li>
Open Emby, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Webhooks
</span>
</li>
<li>Add a new webhook and paste the URL above</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">Playback</span> event
category
</li>
<li>
Sofa will automatically log movies and episodes when you finish
watching them
</li>
</>
),
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: (
<>
<li>
Open Sonarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
</li>
<li>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</li>
<li>Paste the Sonarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li>
Titles on your Sofa watchlist will be automatically added for download
when Sonarr polls this list (every 6 hours by default)
</li>
</>
),
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: (
<>
<li>
Open Radarr, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Import Lists
</span>
</li>
<li>
Click <span className="font-medium text-foreground">+</span> and
select{" "}
<span className="font-medium text-foreground">Custom Lists</span>
</li>
<li>Paste the Radarr URL above into the List URL field</li>
<li>Set your preferred quality profile and root folder</li>
<li>
Titles on your Sofa watchlist will be automatically added for download
when Radarr polls this list (every 12 hours by default)
</li>
</>
),
docsUrl: "https://wiki.servarr.com/radarr/settings#import-lists",
},
];
@@ -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({
</h2>
</div>
<div className="space-y-3">
<WebhookCard provider="plex" />
<WebhookCard provider="jellyfin" />
<WebhookCard provider="emby" />
{INTEGRATION_CONFIGS.map((config) => (
<IntegrationCard key={config.provider} config={config} />
))}
</div>
</div>
);
@@ -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({
) : (
<IconPlayerPlay
aria-hidden={true}
className="size-3 text-muted-foreground/50"
className="size-3 text-muted-foreground/70"
/>
)}
</TooltipTrigger>
@@ -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 (
<Card>
<Collapsible open={cardOpen} onOpenChange={setCardOpen}>
<CardContent className={cardOpen ? "pb-4" : ""}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4 text-primary" />
</div>
<div className="text-left">
<CardTitle>{label}</CardTitle>
<CardDescription>
{connection
? connection.lastEventAt
? `Last event ${formatDistanceToNow(new Date(connection.lastEventAt), { addSuffix: true })}`
: "Ready — no events yet"
: "Not configured"}
</CardDescription>
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
</CardContent>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent className="space-y-3 border-t border-border/30 pt-4">
{isPlex && (
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary">
<IconInfoCircle aria-hidden={true} />
<AlertDescription className="text-foreground/80">
Requires an active{" "}
<a
href="https://www.plex.tv/plex-pass/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
<span>Plex Pass</span>
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
subscription.
</AlertDescription>
</Alert>
)}
{isEmby && (
<Alert className="border-primary/20 bg-primary/5 [&>svg]:text-primary">
<IconInfoCircle aria-hidden={true} />
<AlertDescription className="text-foreground/80">
Requires{" "}
<span className="font-medium text-foreground">
Emby Server 4.7.9+
</span>{" "}
and an active{" "}
<a
href="https://emby.media/premiere.html"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
<span>Emby Premiere</span>
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
license.
</AlertDescription>
</Alert>
)}
{!connection ? (
<Button
onClick={onConnect}
disabled={connecting}
size="lg"
className="w-full"
>
{connecting ? "Connecting…" : `Connect ${label}`}
</Button>
) : (
<AnimatePresence>
{webhookUrl && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div>
<Label
htmlFor={`${provider}-webhook-url`}
className="mb-1 text-muted-foreground"
>
Webhook URL
</Label>
<InputGroup>
<InputGroupInput
id={`${provider}-webhook-url`}
readOnly
value={webhookUrl}
className="font-mono text-[10px] text-muted-foreground"
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconCheck className="text-green-400" />
) : (
<IconCopy />
)}
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRegenerateToken()}
>
<IconRefresh />
Regenerate URL
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete()}
>
<IconTrash />
Disconnect
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)}
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
<IconChevronDown
aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
/>
Setup instructions
</CollapsibleTrigger>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<div className="mt-2 rounded-lg bg-muted/30 border border-border/50 p-3 text-xs leading-relaxed text-muted-foreground">
<ol className="list-inside list-decimal space-y-1.5">
{isPlex ? (
<>
<li>
Open Plex, go to{" "}
<a
href="https://app.plex.tv/desktop/#!/settings/webhooks"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Settings &gt; Webhooks
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</li>
<li>
Click{" "}
<span className="font-medium text-foreground">
Add Webhook
</span>{" "}
and paste the URL above
</li>
</>
) : isEmby ? (
<>
<li>
Open Emby, go to{" "}
<span className="font-medium text-foreground">
Settings &gt; Webhooks
</span>
</li>
<li>Add a new webhook and paste the URL above</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">
Playback
</span>{" "}
event category
</li>
</>
) : (
<>
<li>
Install the{" "}
<a
href="https://github.com/jellyfin/jellyfin-plugin-webhook/tree/master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Webhook plugin
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
from Jellyfin&apos;s plugin catalog
</li>
<li>
Go to{" "}
<span className="font-medium text-foreground">
Dashboard &gt; Plugins &gt; Webhook
</span>
</li>
<li>
Add a{" "}
<span className="font-medium text-foreground">
Generic Destination
</span>{" "}
and paste the URL above
</li>
<li>
Enable the{" "}
<span className="font-medium text-foreground">
Playback Stop
</span>{" "}
notification type
</li>
</>
)}
<li>
Sofa will automatically log movies and episodes when you
finish watching them
</li>
</ol>
<p className="mt-2 -ml-0.5">
<IconBook2
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px] mr-1"
/>
Need more help?{" "}
<a
href={
isPlex
? "https://support.plex.tv/hc/en-us/articles/115002267687-Webhooks/"
: isEmby
? "https://emby.media/support/articles/Webhooks.html"
: "https://jellyfin.org/docs/general/server/notifications/"
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Open docs{" "}
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</p>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
+8 -7
View File
@@ -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,
+19 -29
View File
@@ -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<string, string> = {
sort_by: sortBy,
sort_by,
"vote_count.gte": "50",
};
if (genre) {
+7 -5
View File
@@ -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<ImageCategory>([
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 });
+26
View File
@@ -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));
}
+9 -20
View File
@@ -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(
+9 -20
View File
@@ -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(
+13 -11
View File
@@ -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);
@@ -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`;
File diff suppressed because it is too large Load Diff
+81 -76
View File
@@ -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<void> {
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<void> {
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();
}
+4 -2
View File
@@ -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) {
+10 -19
View File
@@ -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<WebhookConnection[]>([]);
export const connectionsAtom = atom<IntegrationConnection[]>([]);
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`);
}
+14 -14
View File
@@ -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,
),
],
+2 -2
View File
@@ -47,8 +47,8 @@ const REQUIRED_TABLES = [
"userRatings",
"userTitleStatus",
"verification",
"webhookConnections",
"webhookEventLog",
"integrations",
"integrationEvents",
] as const;
let backupOpQueue: Promise<void> = Promise.resolve();
+212
View File
@@ -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);
});
});
+114
View File
@@ -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<typeof statusSchema>;
/** 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;
}
+3
View File
@@ -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))
+6 -6
View File
@@ -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();
}
+24
View File
@@ -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,
+6 -1
View File
@@ -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<TmdbTvDetails>(`/tv/${tmdbId}`, {
append_to_response: "content_ratings",
append_to_response: "content_ratings,external_ids",
});
}
export async function getTvExternalIds(tmdbId: number) {
return tmdbFetch<TmdbExternalIds>(`/tv/${tmdbId}/external_ids`);
}
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
}
+6
View File
@@ -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;