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>) {
|
export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -38,7 +57,7 @@ export function EmbyIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
|
export function SonarrIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -48,10 +67,29 @@ export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...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
|
<path
|
||||||
fill="currentColor"
|
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>
|
</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 { IconWebhook } from "@tabler/icons-react";
|
||||||
import { useHydrateAtoms } from "jotai/utils";
|
import { useHydrateAtoms } from "jotai/utils";
|
||||||
import { connectionsAtom } from "@/lib/atoms/integrations";
|
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({
|
export function IntegrationsSection({
|
||||||
initialConnections,
|
initialConnections,
|
||||||
}: {
|
}: {
|
||||||
initialConnections: WebhookConnection[];
|
initialConnections: IntegrationConnection[];
|
||||||
}) {
|
}) {
|
||||||
useHydrateAtoms([[connectionsAtom, initialConnections]]);
|
useHydrateAtoms([[connectionsAtom, initialConnections]]);
|
||||||
|
|
||||||
@@ -24,9 +28,9 @@ export function IntegrationsSection({
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<WebhookCard provider="plex" />
|
{INTEGRATION_CONFIGS.map((config) => (
|
||||||
<WebhookCard provider="jellyfin" />
|
<IntegrationCard key={config.provider} config={config} />
|
||||||
<WebhookCard provider="emby" />
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ function RefreshButton({
|
|||||||
aria-label="Refresh system health"
|
aria-label="Refresh system health"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -601,7 +602,7 @@ function BackgroundJobsCard({
|
|||||||
) : (
|
) : (
|
||||||
<IconPlayerPlay
|
<IconPlayerPlay
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="size-3 text-muted-foreground/50"
|
className="size-3 text-muted-foreground/70"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</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 { Card } from "@/components/ui/card";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import { db } from "@/lib/db/client";
|
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 { listBackups } from "@/lib/services/backup";
|
||||||
import { getSetting } from "@/lib/services/settings";
|
import { getSetting } from "@/lib/services/settings";
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +34,8 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const connRows = db
|
const connRows = db
|
||||||
.select()
|
.select()
|
||||||
.from(webhookConnections)
|
.from(integrations)
|
||||||
.where(eq(webhookConnections.userId, session.user.id))
|
.where(eq(integrations.userId, session.user.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const connIds = connRows.map((c) => c.id);
|
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)
|
// Fetch only the 10 most recent events per connection (index-optimized)
|
||||||
const eventsByConn = new Map<
|
const eventsByConn = new Map<
|
||||||
string,
|
string,
|
||||||
(typeof webhookEventLog.$inferSelect)[]
|
(typeof integrationEvents.$inferSelect)[]
|
||||||
>();
|
>();
|
||||||
for (const connId of connIds) {
|
for (const connId of connIds) {
|
||||||
const events = db
|
const events = db
|
||||||
.select()
|
.select()
|
||||||
.from(webhookEventLog)
|
.from(integrationEvents)
|
||||||
.where(eq(webhookEventLog.connectionId, connId))
|
.where(eq(integrationEvents.integrationId, connId))
|
||||||
.orderBy(desc(webhookEventLog.receivedAt))
|
.orderBy(desc(integrationEvents.receivedAt))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all();
|
.all();
|
||||||
eventsByConn.set(connId, events);
|
eventsByConn.set(connId, events);
|
||||||
@@ -59,6 +59,7 @@ export default async function SettingsPage() {
|
|||||||
const connections = connRows.map((conn) => ({
|
const connections = connRows.map((conn) => ({
|
||||||
id: conn.id,
|
id: conn.id,
|
||||||
provider: conn.provider,
|
provider: conn.provider,
|
||||||
|
type: conn.type,
|
||||||
token: conn.token,
|
token: conn.token,
|
||||||
enabled: conn.enabled,
|
enabled: conn.enabled,
|
||||||
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { isTmdbConfigured } from "@/lib/config";
|
import { isTmdbConfigured } from "@/lib/config";
|
||||||
import { discover } from "@/lib/tmdb/client";
|
import { discover } from "@/lib/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||||
|
|
||||||
const SORT_BY_PATTERN = /^[a-z_]+\.(asc|desc)$/;
|
const querySchema = z.object({
|
||||||
const MAX_PAGE = 500;
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
@@ -26,40 +37,19 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = req.nextUrl;
|
const raw = Object.fromEntries(req.nextUrl.searchParams);
|
||||||
const rawType = searchParams.get("type");
|
const result = querySchema.safeParse(raw);
|
||||||
if (rawType && rawType !== "movie" && rawType !== "tv") {
|
if (!result.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "type must be movie or tv" },
|
{ error: result.error.issues[0].message },
|
||||||
{ 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}` },
|
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SORT_BY_PATTERN.test(sortBy)) {
|
const { type, sort_by, genre, page } = result.data;
|
||||||
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 params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
sort_by: sortBy,
|
sort_by,
|
||||||
"vote_count.gte": "50",
|
"vote_count.gte": "50",
|
||||||
};
|
};
|
||||||
if (genre) {
|
if (genre) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
fetchAndMaybeCache,
|
fetchAndMaybeCache,
|
||||||
type ImageCategory,
|
|
||||||
imageCacheEnabled,
|
imageCacheEnabled,
|
||||||
} from "@/lib/services/image-cache";
|
} from "@/lib/services/image-cache";
|
||||||
|
|
||||||
const VALID_CATEGORIES = new Set<ImageCategory>([
|
const categorySchema = z.enum([
|
||||||
"posters",
|
"posters",
|
||||||
"backdrops",
|
"backdrops",
|
||||||
"stills",
|
"stills",
|
||||||
@@ -33,11 +33,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
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 });
|
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const category = catResult.data;
|
||||||
|
|
||||||
// Sanitize filename — only allow basename to prevent path traversal
|
// Sanitize filename — only allow basename to prevent path traversal
|
||||||
const filename = path.basename(rawFilename);
|
const filename = path.basename(rawFilename);
|
||||||
@@ -46,7 +48,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tmdbPath = `/${filename}`;
|
const tmdbPath = `/${filename}`;
|
||||||
const result = await fetchAndMaybeCache(tmdbPath, category as ImageCategory);
|
const result = await fetchAndMaybeCache(tmdbPath, category);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
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 { headers } from "next/headers";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { importTitle } from "@/lib/services/metadata";
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
@@ -12,25 +18,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
const result = bodySchema.safeParse(await req.json().catch(() => null));
|
||||||
try {
|
if (!result.success) {
|
||||||
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")
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -38,7 +27,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const title = await importTitle(tmdbId, type);
|
const title = await importTitle(result.data.tmdbId, result.data.type);
|
||||||
return NextResponse.json(title);
|
return NextResponse.json(title);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { importTitle } from "@/lib/services/metadata";
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
@@ -12,25 +18,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
const result = bodySchema.safeParse(await req.json().catch(() => null));
|
||||||
try {
|
if (!result.success) {
|
||||||
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")
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -38,7 +27,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const title = await importTitle(tmdbId, type);
|
const title = await importTitle(result.data.tmdbId, result.data.type);
|
||||||
return NextResponse.json({ id: title?.id });
|
return NextResponse.json({ id: title?.id });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { webhookConnections } from "@/lib/db/schema";
|
import { integrations } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import type { WebhookEvent } from "@/lib/services/webhooks";
|
import type { WebhookEvent } from "@/lib/services/webhooks";
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +23,8 @@ export async function POST(
|
|||||||
// Look up connection by token — this IS the auth
|
// Look up connection by token — this IS the auth
|
||||||
const connection = db
|
const connection = db
|
||||||
.select()
|
.select()
|
||||||
.from(webhookConnections)
|
.from(integrations)
|
||||||
.where(eq(webhookConnections.token, token))
|
.where(eq(integrations.token, token))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!connection || !connection.enabled) {
|
if (!connection || !connection.enabled) {
|
||||||
@@ -32,12 +32,19 @@ export async function POST(
|
|||||||
return NextResponse.json({ ok: true });
|
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 {
|
try {
|
||||||
let event: WebhookEvent | null;
|
let event: WebhookEvent | null;
|
||||||
if (connection.provider === "plex") {
|
if (provider === "plex") {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
event = parsePlexPayload(formData);
|
event = parsePlexPayload(formData);
|
||||||
} else if (connection.provider === "emby") {
|
} else if (provider === "emby") {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
event = parseEmbyPayload(body);
|
event = parseEmbyPayload(body);
|
||||||
} else {
|
} else {
|
||||||
@@ -50,12 +57,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await processWebhook(
|
await processWebhook(connection.id, connection.userId, provider, event);
|
||||||
connection.id,
|
|
||||||
connection.userId,
|
|
||||||
connection.provider,
|
|
||||||
event,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Swallow errors — never return non-200 to media servers
|
// Swallow errors — never return non-200 to media servers
|
||||||
log.debug("Webhook processing failed:", err);
|
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 { and, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { type BackupFrequency, rescheduleBackup } from "@/lib/cron";
|
import { type BackupFrequency, rescheduleBackup } from "@/lib/cron";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { webhookConnections } from "@/lib/db/schema";
|
import { integrations } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
type BackupInfo,
|
type BackupInfo,
|
||||||
createBackup,
|
createBackup,
|
||||||
@@ -14,6 +15,14 @@ import {
|
|||||||
} from "@/lib/services/backup";
|
} from "@/lib/services/backup";
|
||||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
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() {
|
async function getSession() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) throw new Error("Unauthorized");
|
if (!session) throw new Error("Unauthorized");
|
||||||
@@ -26,124 +35,106 @@ async function getAdminSession() {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Webhook actions ---
|
function generateToken() {
|
||||||
|
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||||
|
"hex",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveWebhookConnection(
|
// --- Integration actions ---
|
||||||
provider: "plex" | "jellyfin" | "emby",
|
|
||||||
enabled?: boolean,
|
export async function saveIntegration(provider: string, enabled?: boolean) {
|
||||||
) {
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
const parsed = providerSchema.parse(provider);
|
||||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
|
||||||
throw new Error("Invalid provider");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
.from(webhookConnections)
|
.from(integrations)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(webhookConnections.userId, session.user.id),
|
eq(integrations.userId, session.user.id),
|
||||||
eq(webhookConnections.provider, provider),
|
eq(integrations.provider, parsed),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const connection = db
|
const row = db
|
||||||
.update(webhookConnections)
|
.update(integrations)
|
||||||
.set({
|
.set({
|
||||||
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
||||||
})
|
})
|
||||||
.where(eq(webhookConnections.id, existing.id))
|
.where(eq(integrations.id, existing.id))
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
return {
|
return {
|
||||||
...connection,
|
...row,
|
||||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||||
createdAt: connection.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = Buffer.from(
|
const row = db
|
||||||
crypto.getRandomValues(new Uint8Array(32)),
|
.insert(integrations)
|
||||||
).toString("hex");
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const connection = db
|
|
||||||
.insert(webhookConnections)
|
|
||||||
.values({
|
.values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
provider,
|
provider: parsed,
|
||||||
token,
|
type: integrationTypeFor(parsed),
|
||||||
|
token: generateToken(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: now,
|
createdAt: new Date(),
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...connection,
|
...row,
|
||||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||||
createdAt: connection.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWebhookConnection(
|
export async function deleteIntegration(provider: string) {
|
||||||
provider: "plex" | "jellyfin" | "emby",
|
|
||||||
) {
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
const parsed = providerSchema.parse(provider);
|
||||||
|
|
||||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
db.delete(integrations)
|
||||||
throw new Error("Invalid provider");
|
|
||||||
}
|
|
||||||
|
|
||||||
db.delete(webhookConnections)
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(webhookConnections.userId, session.user.id),
|
eq(integrations.userId, session.user.id),
|
||||||
eq(webhookConnections.provider, provider),
|
eq(integrations.provider, parsed),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateWebhookToken(
|
export async function regenerateIntegrationToken(provider: string) {
|
||||||
provider: "plex" | "jellyfin" | "emby",
|
|
||||||
) {
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
const parsed = providerSchema.parse(provider);
|
||||||
|
|
||||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
const row = db
|
||||||
throw new Error("Invalid provider");
|
.update(integrations)
|
||||||
}
|
.set({ token: generateToken() })
|
||||||
|
|
||||||
const newToken = Buffer.from(
|
|
||||||
crypto.getRandomValues(new Uint8Array(32)),
|
|
||||||
).toString("hex");
|
|
||||||
|
|
||||||
const connection = db
|
|
||||||
.update(webhookConnections)
|
|
||||||
.set({ token: newToken })
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(webhookConnections.userId, session.user.id),
|
eq(integrations.userId, session.user.id),
|
||||||
eq(webhookConnections.provider, provider),
|
eq(integrations.provider, parsed),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!connection) {
|
if (!row) throw new Error("Integration not found");
|
||||||
throw new Error("Connection not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...connection,
|
...row,
|
||||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||||
createdAt: connection.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Admin actions ---
|
||||||
|
|
||||||
export async function toggleRegistration(open: boolean) {
|
export async function toggleRegistration(open: boolean) {
|
||||||
await getAdminSession();
|
await getAdminSession();
|
||||||
setSetting("registrationOpen", String(open));
|
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> {
|
export async function setMaxBackupsAction(max: number): Promise<void> {
|
||||||
await getAdminSession();
|
await getAdminSession();
|
||||||
if (max < 0 || (max > 30 && max !== 0))
|
maxBackupsSchema.parse(max);
|
||||||
throw new Error("Max backups must be between 1 and 30, or 0 for unlimited");
|
|
||||||
setSetting("maxBackupRetention", String(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(
|
export async function setBackupScheduleAction(
|
||||||
frequency: BackupFrequency,
|
frequency: BackupFrequency,
|
||||||
@@ -204,14 +214,9 @@ export async function setBackupScheduleAction(
|
|||||||
dayOfWeek = 0,
|
dayOfWeek = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await getAdminSession();
|
await getAdminSession();
|
||||||
if (!VALID_FREQUENCIES.includes(frequency))
|
const parsed = backupScheduleSchema.parse({ frequency, time, dayOfWeek });
|
||||||
throw new Error("Invalid frequency");
|
setSetting("backupScheduleFrequency", parsed.frequency);
|
||||||
if (!/^\d{2}:\d{2}$/.test(time)) throw new Error("Invalid time format");
|
setSetting("backupScheduleTime", parsed.time);
|
||||||
const [h, m] = time.split(":").map(Number);
|
setSetting("backupScheduleDow", String(parsed.dayOfWeek));
|
||||||
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));
|
|
||||||
rescheduleBackup();
|
rescheduleBackup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { episodes } from "@/lib/db/schema";
|
import { episodes } from "@/lib/db/schema";
|
||||||
@@ -40,10 +41,11 @@ export async function markAllWatchedAction(titleId: string) {
|
|||||||
markAllEpisodesWatched(userId, titleId);
|
markAllEpisodesWatched(userId, titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ratingSchema = z.number().int().min(0).max(5);
|
||||||
|
|
||||||
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
||||||
const userId = await getSessionUserId();
|
const userId = await getSessionUserId();
|
||||||
if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating");
|
rateTitleStars(userId, titleId, ratingSchema.parse(ratingStars));
|
||||||
rateTitleStars(userId, titleId, ratingStars);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function watchMovie(titleId: string) {
|
export async function watchMovie(titleId: string) {
|
||||||
|
|||||||
+10
-19
@@ -1,31 +1,22 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner";
|
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 {
|
import {
|
||||||
deleteWebhookConnection,
|
deleteIntegration,
|
||||||
regenerateWebhookToken,
|
regenerateIntegrationToken,
|
||||||
saveWebhookConnection,
|
saveIntegration,
|
||||||
} from "@/lib/actions/settings";
|
} from "@/lib/actions/settings";
|
||||||
|
|
||||||
export const connectionsAtom = atom<WebhookConnection[]>([]);
|
export const connectionsAtom = atom<IntegrationConnection[]>([]);
|
||||||
|
|
||||||
function providerLabel(provider: "plex" | "jellyfin" | "emby") {
|
export function useConnectionActions(provider: string, label: string) {
|
||||||
return provider === "plex"
|
|
||||||
? "Plex"
|
|
||||||
: provider === "emby"
|
|
||||||
? "Emby"
|
|
||||||
: "Jellyfin";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
|
||||||
const [connections, setConnections] = useAtom(connectionsAtom);
|
const [connections, setConnections] = useAtom(connectionsAtom);
|
||||||
const label = providerLabel(provider);
|
|
||||||
const connection = connections.find((c) => c.provider === provider) ?? null;
|
const connection = connections.find((c) => c.provider === provider) ?? null;
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
const handleConnect = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await saveWebhookConnection(provider);
|
const result = await saveIntegration(provider);
|
||||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||||
toast.success(`${label} connected`);
|
toast.success(`${label} connected`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -37,7 +28,7 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
|||||||
const previous = connections;
|
const previous = connections;
|
||||||
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
||||||
try {
|
try {
|
||||||
await deleteWebhookConnection(provider);
|
await deleteIntegration(provider);
|
||||||
toast.success(`${label} disconnected`);
|
toast.success(`${label} disconnected`);
|
||||||
} catch {
|
} catch {
|
||||||
setConnections(previous);
|
setConnections(previous);
|
||||||
@@ -47,13 +38,13 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
|||||||
|
|
||||||
const handleRegenerateToken = useCallback(async () => {
|
const handleRegenerateToken = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await regenerateWebhookToken(provider);
|
const result = await regenerateIntegrationToken(provider);
|
||||||
setConnections((prev) =>
|
setConnections((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
c.provider === provider ? { ...c, token: result.token } : c,
|
c.provider === provider ? { ...c, token: result.token } : c,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
toast.success(`${label} webhook URL regenerated`);
|
toast.success(`${label} URL regenerated`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to regenerate ${label} URL`);
|
toast.error(`Failed to regenerate ${label} URL`);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -102,6 +102,7 @@ export const titles = sqliteTable(
|
|||||||
{
|
{
|
||||||
id: uuidPk(),
|
id: uuidPk(),
|
||||||
tmdbId: int("tmdbId").notNull(),
|
tmdbId: int("tmdbId").notNull(),
|
||||||
|
tvdbId: int("tvdbId"),
|
||||||
type: text("type", { enum: ["movie", "tv"] }).notNull(),
|
type: text("type", { enum: ["movie", "tv"] }).notNull(),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
originalTitle: text("originalTitle"),
|
originalTitle: text("originalTitle"),
|
||||||
@@ -374,39 +375,38 @@ export const titleCast = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Webhook Connections ─────────────────────────────────────────────
|
// ─── Integrations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const webhookConnections = sqliteTable(
|
export const integrations = sqliteTable(
|
||||||
"webhookConnections",
|
"integrations",
|
||||||
{
|
{
|
||||||
id: uuidPk(),
|
id: uuidPk(),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
provider: text("provider", {
|
provider: text("provider").notNull(),
|
||||||
enum: ["plex", "jellyfin", "emby"],
|
type: text("type", { enum: ["webhook", "list"] }).notNull(),
|
||||||
}).notNull(),
|
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
|
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
|
||||||
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
|
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("webhookConnections_userId_provider").on(
|
uniqueIndex("integrations_userId_provider").on(
|
||||||
table.userId,
|
table.userId,
|
||||||
table.provider,
|
table.provider,
|
||||||
),
|
),
|
||||||
uniqueIndex("webhookConnections_token").on(table.token),
|
uniqueIndex("integrations_token").on(table.token),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const webhookEventLog = sqliteTable(
|
export const integrationEvents = sqliteTable(
|
||||||
"webhookEventLog",
|
"integrationEvents",
|
||||||
{
|
{
|
||||||
id: uuidPk(),
|
id: uuidPk(),
|
||||||
connectionId: text("connectionId")
|
integrationId: text("integrationId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => webhookConnections.id, { onDelete: "cascade" }),
|
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||||
eventType: text("eventType"),
|
eventType: text("eventType"),
|
||||||
mediaType: text("mediaType"),
|
mediaType: text("mediaType"),
|
||||||
mediaTitle: text("mediaTitle"),
|
mediaTitle: text("mediaTitle"),
|
||||||
@@ -417,8 +417,8 @@ export const webhookEventLog = sqliteTable(
|
|||||||
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
|
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("webhookEventLog_connectionId_receivedAt").on(
|
index("integrationEvents_integrationId_receivedAt").on(
|
||||||
table.connectionId,
|
table.integrationId,
|
||||||
table.receivedAt,
|
table.receivedAt,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ const REQUIRED_TABLES = [
|
|||||||
"userRatings",
|
"userRatings",
|
||||||
"userTitleStatus",
|
"userTitleStatus",
|
||||||
"verification",
|
"verification",
|
||||||
"webhookConnections",
|
"integrations",
|
||||||
"webhookEventLog",
|
"integrationEvents",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
let backupOpQueue: Promise<void> = Promise.resolve();
|
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,
|
backdropPath: show.backdrop_path,
|
||||||
status: show.status,
|
status: show.status,
|
||||||
contentRating: extractTvContentRating(show),
|
contentRating: extractTvContentRating(show),
|
||||||
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(titles.id, existing.id))
|
.where(eq(titles.id, existing.id))
|
||||||
@@ -238,6 +239,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
|||||||
const row = insertTitleOrGet(
|
const row = insertTitleOrGet(
|
||||||
{
|
{
|
||||||
tmdbId: show.id,
|
tmdbId: show.id,
|
||||||
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
type: "tv",
|
type: "tv",
|
||||||
title: show.name,
|
title: show.name,
|
||||||
originalTitle: show.original_name,
|
originalTitle: show.original_name,
|
||||||
@@ -326,6 +328,7 @@ export async function refreshTitle(titleId: string) {
|
|||||||
voteCount: show.vote_count,
|
voteCount: show.vote_count,
|
||||||
status: show.status,
|
status: show.status,
|
||||||
contentRating: extractTvContentRating(show),
|
contentRating: extractTvContentRating(show),
|
||||||
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
lastFetchedAt: now,
|
lastFetchedAt: now,
|
||||||
})
|
})
|
||||||
.where(eq(titles.id, titleId))
|
.where(eq(titles.id, titleId))
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { and, eq, gte } from "drizzle-orm";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import {
|
import {
|
||||||
episodes,
|
episodes,
|
||||||
|
integrationEvents,
|
||||||
|
integrations,
|
||||||
seasons,
|
seasons,
|
||||||
userEpisodeWatches,
|
userEpisodeWatches,
|
||||||
userMovieWatches,
|
userMovieWatches,
|
||||||
webhookConnections,
|
|
||||||
webhookEventLog,
|
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { findByExternalId, searchTv } from "@/lib/tmdb/client";
|
import { findByExternalId, searchTv } from "@/lib/tmdb/client";
|
||||||
@@ -283,9 +283,9 @@ function logEvent(
|
|||||||
status: "success" | "ignored" | "error",
|
status: "success" | "ignored" | "error",
|
||||||
errorMessage?: string,
|
errorMessage?: string,
|
||||||
) {
|
) {
|
||||||
db.insert(webhookEventLog)
|
db.insert(integrationEvents)
|
||||||
.values({
|
.values({
|
||||||
connectionId,
|
integrationId: connectionId,
|
||||||
eventType:
|
eventType:
|
||||||
event?.provider === "plex"
|
event?.provider === "plex"
|
||||||
? "media.scrobble"
|
? "media.scrobble"
|
||||||
@@ -300,9 +300,9 @@ function logEvent(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
db.update(webhookConnections)
|
db.update(integrations)
|
||||||
.set({ lastEventAt: new Date() })
|
.set({ lastEventAt: new Date() })
|
||||||
.where(eq(webhookConnections.id, connectionId))
|
.where(eq(integrations.id, connectionId))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
userRatings,
|
userRatings,
|
||||||
availabilityOffers,
|
availabilityOffers,
|
||||||
titleRecommendations,
|
titleRecommendations,
|
||||||
|
integrations,
|
||||||
} = schema;
|
} = schema;
|
||||||
|
|
||||||
export const testClient = new Database(":memory:");
|
export const testClient = new Database(":memory:");
|
||||||
@@ -58,6 +59,7 @@ export function insertTitle(
|
|||||||
overrides: {
|
overrides: {
|
||||||
id?: string;
|
id?: string;
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
|
tvdbId?: number;
|
||||||
type?: "movie" | "tv";
|
type?: "movie" | "tv";
|
||||||
title?: string;
|
title?: string;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -68,6 +70,7 @@ export function insertTitle(
|
|||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
tmdbId: overrides.tmdbId ?? 12345,
|
tmdbId: overrides.tmdbId ?? 12345,
|
||||||
|
tvdbId: overrides.tvdbId,
|
||||||
type: overrides.type ?? "movie",
|
type: overrides.type ?? "movie",
|
||||||
title: overrides.title ?? "Test Movie",
|
title: overrides.title ?? "Test Movie",
|
||||||
})
|
})
|
||||||
@@ -180,6 +183,27 @@ export function insertAvailabilityOffer(
|
|||||||
.run();
|
.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(
|
export function insertRecommendation(
|
||||||
titleId: string,
|
titleId: string,
|
||||||
recommendedTitleId: string,
|
recommendedTitleId: string,
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import type {
|
import type {
|
||||||
|
TmdbExternalIds,
|
||||||
TmdbFindResult,
|
TmdbFindResult,
|
||||||
TmdbGenreListResponse,
|
TmdbGenreListResponse,
|
||||||
TmdbMovieCreditsResponse,
|
TmdbMovieCreditsResponse,
|
||||||
@@ -90,10 +91,14 @@ export async function getMovieDetails(tmdbId: number) {
|
|||||||
|
|
||||||
export async function getTvDetails(tmdbId: number) {
|
export async function getTvDetails(tmdbId: number) {
|
||||||
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, {
|
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) {
|
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||||
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
|
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,15 @@ export interface TmdbTvDetails {
|
|||||||
content_ratings?: {
|
content_ratings?: {
|
||||||
results: { iso_3166_1: string; rating: string }[];
|
results: { iso_3166_1: string; rating: string }[];
|
||||||
};
|
};
|
||||||
|
external_ids?: TmdbExternalIds;
|
||||||
seasons: TmdbSeasonSummary[];
|
seasons: TmdbSeasonSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbExternalIds {
|
||||||
|
tvdb_id: number | null;
|
||||||
|
imdb_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbSeasonSummary {
|
export interface TmdbSeasonSummary {
|
||||||
id: number;
|
id: number;
|
||||||
season_number: number;
|
season_number: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user