mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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 > 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's plugin catalog
|
||||
</li>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Dashboard > Plugins > 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 > 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 > 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 > 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 > 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 > 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's plugin catalog
|
||||
</li>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Dashboard > Plugins > 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -47,8 +47,8 @@ const REQUIRED_TABLES = [
|
||||
"userRatings",
|
||||
"userTitleStatus",
|
||||
"verification",
|
||||
"webhookConnections",
|
||||
"webhookEventLog",
|
||||
"integrations",
|
||||
"integrationEvents",
|
||||
] as const;
|
||||
|
||||
let backupOpQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user