mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
Add Emby webhook integration and remove unused username field
Add Emby as a third media server integration alongside Plex and Jellyfin. Emby webhooks use a similar payload format (JSON with nested Item object and ProviderIds). Also removes the mediaServerUsername field from all webhook connections since it was never used for authentication — the token-in-URL is the sole auth mechanism. This simplifies the connection UX to a single "Connect" button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11 files changed
+2292
-178
No files matched your search
@@ -19,6 +19,25 @@ export function PlexIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EmbyIcon(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="M11.041 0L0 17.98l11.041 6.02 12.959-6.02zm.058 6.905l7.348 10.607-7.348 3.986-7.306-3.986z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
|
export function JellyfinIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -21,31 +21,23 @@ export function IntegrationsSection({
|
|||||||
const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
|
const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
|
||||||
const jellyfinConnection =
|
const jellyfinConnection =
|
||||||
connections.find((c) => c.provider === "jellyfin") ?? null;
|
connections.find((c) => c.provider === "jellyfin") ?? null;
|
||||||
|
const embyConnection = connections.find((c) => c.provider === "emby") ?? null;
|
||||||
|
|
||||||
async function handleSave(provider: "plex" | "jellyfin", username: string) {
|
async function handleConnect(provider: "plex" | "jellyfin" | "emby") {
|
||||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
const label =
|
||||||
const isNew = !connections.find((c) => c.provider === provider);
|
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||||
try {
|
try {
|
||||||
const result = await saveWebhookConnection(provider, username);
|
const result = await saveWebhookConnection(provider);
|
||||||
setConnections((prev) => {
|
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||||
const existing = prev.find((c) => c.provider === provider);
|
toast.success(`${label} connected`);
|
||||||
if (existing) {
|
|
||||||
return prev.map((c) =>
|
|
||||||
c.provider === provider
|
|
||||||
? { ...result, recentEvents: existing.recentEvents }
|
|
||||||
: c,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [...prev, { ...result, recentEvents: [] }];
|
|
||||||
});
|
|
||||||
toast.success(isNew ? `${label} connected` : `${label} updated`);
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to save ${label} connection`);
|
toast.error(`Failed to connect ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(provider: "plex" | "jellyfin") {
|
async function handleDelete(provider: "plex" | "jellyfin" | "emby") {
|
||||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
const label =
|
||||||
|
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||||
const previous = connections;
|
const previous = connections;
|
||||||
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
||||||
try {
|
try {
|
||||||
@@ -57,8 +49,9 @@ export function IntegrationsSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegenerateToken(provider: "plex" | "jellyfin") {
|
async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") {
|
||||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
const label =
|
||||||
|
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||||
try {
|
try {
|
||||||
const result = await regenerateWebhookToken(provider);
|
const result = await regenerateWebhookToken(provider);
|
||||||
setConnections((prev) =>
|
setConnections((prev) =>
|
||||||
@@ -72,19 +65,18 @@ export function IntegrationsSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) {
|
async function handleToggle(
|
||||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const label =
|
||||||
|
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||||
const previous = connections;
|
const previous = connections;
|
||||||
setConnections((prev) =>
|
setConnections((prev) =>
|
||||||
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
|
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const conn = connections.find((c) => c.provider === provider);
|
await saveWebhookConnection(provider, enabled);
|
||||||
await saveWebhookConnection(
|
|
||||||
provider,
|
|
||||||
conn?.mediaServerUsername ?? "",
|
|
||||||
enabled,
|
|
||||||
);
|
|
||||||
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
|
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
|
||||||
} catch {
|
} catch {
|
||||||
setConnections(previous);
|
setConnections(previous);
|
||||||
@@ -104,7 +96,7 @@ export function IntegrationsSection({
|
|||||||
<WebhookCard
|
<WebhookCard
|
||||||
provider="plex"
|
provider="plex"
|
||||||
connection={plexConnection}
|
connection={plexConnection}
|
||||||
onSave={handleSave}
|
onConnect={handleConnect}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRegenerateToken={handleRegenerateToken}
|
onRegenerateToken={handleRegenerateToken}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
@@ -112,7 +104,15 @@ export function IntegrationsSection({
|
|||||||
<WebhookCard
|
<WebhookCard
|
||||||
provider="jellyfin"
|
provider="jellyfin"
|
||||||
connection={jellyfinConnection}
|
connection={jellyfinConnection}
|
||||||
onSave={handleSave}
|
onConnect={handleConnect}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRegenerateToken={handleRegenerateToken}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
<WebhookCard
|
||||||
|
provider="emby"
|
||||||
|
connection={embyConnection}
|
||||||
|
onConnect={handleConnect}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRegenerateToken={handleRegenerateToken}
|
onRegenerateToken={handleRegenerateToken}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
|
|||||||
@@ -31,13 +31,12 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { JellyfinIcon, PlexIcon } from "./icons";
|
import { EmbyIcon, JellyfinIcon, PlexIcon } from "./icons";
|
||||||
|
|
||||||
export interface WebhookConnection {
|
export interface WebhookConnection {
|
||||||
id: string;
|
id: string;
|
||||||
provider: "plex" | "jellyfin";
|
provider: "plex" | "jellyfin" | "emby";
|
||||||
token: string;
|
token: string;
|
||||||
mediaServerUsername: string;
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastEventAt: string | null;
|
lastEventAt: string | null;
|
||||||
recentEvents: {
|
recentEvents: {
|
||||||
@@ -53,41 +52,41 @@ export interface WebhookConnection {
|
|||||||
export function WebhookCard({
|
export function WebhookCard({
|
||||||
provider,
|
provider,
|
||||||
connection,
|
connection,
|
||||||
onSave,
|
onConnect,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRegenerateToken,
|
onRegenerateToken,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
provider: "plex" | "jellyfin";
|
provider: "plex" | "jellyfin" | "emby";
|
||||||
connection: WebhookConnection | null;
|
connection: WebhookConnection | null;
|
||||||
onSave: (provider: "plex" | "jellyfin", username: string) => Promise<void>;
|
onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||||
onDelete: (provider: "plex" | "jellyfin") => Promise<void>;
|
onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||||
onRegenerateToken: (provider: "plex" | "jellyfin") => Promise<void>;
|
onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||||
onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise<void>;
|
onToggle: (
|
||||||
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
|
enabled: boolean,
|
||||||
|
) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [username, setUsername] = useState(
|
const [connecting, setConnecting] = useState(false);
|
||||||
connection?.mediaServerUsername ?? "",
|
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
const [cardOpen, setCardOpen] = useState(false);
|
const [cardOpen, setCardOpen] = useState(false);
|
||||||
|
|
||||||
const isPlex = provider === "plex";
|
const isPlex = provider === "plex";
|
||||||
const label = isPlex ? "Plex" : "Jellyfin";
|
const isEmby = provider === "emby";
|
||||||
const Icon = isPlex ? PlexIcon : JellyfinIcon;
|
const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin";
|
||||||
|
const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon;
|
||||||
|
|
||||||
const webhookUrl = connection
|
const webhookUrl = connection
|
||||||
? `${window.location.origin}/api/webhooks/${connection.token}`
|
? `${window.location.origin}/api/webhooks/${connection.token}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleConnect() {
|
||||||
if (!username.trim()) return;
|
setConnecting(true);
|
||||||
setSaving(true);
|
|
||||||
try {
|
try {
|
||||||
await onSave(provider, username.trim());
|
await onConnect(provider);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,116 +146,109 @@ export function WebhookCard({
|
|||||||
href="https://www.plex.tv/plex-pass/"
|
href="https://www.plex.tv/plex-pass/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium text-foreground inline-flex items-center gap-0.5 hover:underline underline-offset-2"
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
<span>Plex Pass</span>
|
<span>Plex Pass</span>
|
||||||
<IconExternalLink className="size-3 inline-block translate-y-[-1px]" />
|
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
subscription.
|
subscription.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{isEmby && (
|
||||||
<label
|
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||||
htmlFor={`${provider}-username`}
|
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
||||||
className="mb-1 block text-xs text-muted-foreground"
|
<p className="text-xs leading-relaxed text-foreground/80">
|
||||||
>
|
Emby webhooks require an active{" "}
|
||||||
{label} username
|
<a
|
||||||
</label>
|
href="https://emby.media/premiere.html"
|
||||||
<div className="flex gap-2">
|
target="_blank"
|
||||||
<Input
|
rel="noopener noreferrer"
|
||||||
id={`${provider}-username`}
|
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
placeholder={`Your ${label} username`}
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
||||||
/>
|
|
||||||
{!connection ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !username.trim()}
|
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Connect"}
|
<span>Emby Premiere</span>
|
||||||
</Button>
|
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||||
) : (
|
</a>{" "}
|
||||||
username.trim() !== connection.mediaServerUsername && (
|
subscription.
|
||||||
<Button
|
</p>
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !username.trim()}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<AnimatePresence>
|
{!connection ? (
|
||||||
{webhookUrl && (
|
<Button
|
||||||
<motion.div
|
onClick={handleConnect}
|
||||||
initial={{ opacity: 0, height: 0 }}
|
disabled={connecting}
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
className="w-full"
|
||||||
exit={{ opacity: 0, height: 0 }}
|
>
|
||||||
className="space-y-3 overflow-hidden"
|
{connecting ? "Connecting..." : `Connect ${label}`}
|
||||||
>
|
</Button>
|
||||||
<div>
|
) : (
|
||||||
<label
|
<AnimatePresence>
|
||||||
htmlFor={`${provider}-webhook-url`}
|
{webhookUrl && (
|
||||||
className="mb-1 block text-xs text-muted-foreground"
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0, height: 0 }}
|
||||||
Webhook URL
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
</label>
|
exit={{ opacity: 0, height: 0 }}
|
||||||
<div className="flex gap-2">
|
className="space-y-3 overflow-hidden"
|
||||||
<Input
|
>
|
||||||
id={`${provider}-webhook-url`}
|
<div>
|
||||||
readOnly
|
<label
|
||||||
value={webhookUrl}
|
htmlFor={`${provider}-webhook-url`}
|
||||||
className="font-mono text-[10px] text-muted-foreground"
|
className="mb-1 block text-xs text-muted-foreground"
|
||||||
/>
|
>
|
||||||
<Tooltip>
|
Webhook URL
|
||||||
<TooltipTrigger
|
</label>
|
||||||
render={
|
<div className="flex gap-2">
|
||||||
<Button
|
<Input
|
||||||
variant="outline"
|
id={`${provider}-webhook-url`}
|
||||||
size="icon"
|
readOnly
|
||||||
onClick={handleCopy}
|
value={webhookUrl}
|
||||||
/>
|
className="font-mono text-[10px] text-muted-foreground"
|
||||||
}
|
/>
|
||||||
>
|
<Tooltip>
|
||||||
{copied ? (
|
<TooltipTrigger
|
||||||
<IconCheck className="text-green-400" />
|
render={
|
||||||
) : (
|
<Button
|
||||||
<IconCopy />
|
variant="outline"
|
||||||
)}
|
size="icon"
|
||||||
</TooltipTrigger>
|
onClick={handleCopy}
|
||||||
<TooltipContent>Copy URL</TooltipContent>
|
/>
|
||||||
</Tooltip>
|
}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<IconCheck className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<IconCopy />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy URL</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRegenerateToken(provider)}
|
onClick={() => onRegenerateToken(provider)}
|
||||||
>
|
>
|
||||||
<IconRefresh />
|
<IconRefresh />
|
||||||
Regenerate URL
|
Regenerate URL
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onDelete(provider)}
|
onClick={() => onDelete(provider)}
|
||||||
>
|
>
|
||||||
<IconTrash />
|
<IconTrash />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
|
||||||
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
<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">
|
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||||
@@ -286,9 +278,26 @@ export function WebhookCard({
|
|||||||
Sofa will automatically log movies and episodes when you
|
Sofa will automatically log movies and episodes when you
|
||||||
finish watching them
|
finish watching them
|
||||||
</li>
|
</li>
|
||||||
|
</ol>
|
||||||
|
) : isEmby ? (
|
||||||
|
<ol className="list-inside list-decimal space-y-1.5">
|
||||||
<li>
|
<li>
|
||||||
Make sure the username above matches your Plex account
|
Open Emby, go to{" "}
|
||||||
name
|
<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 Stop
|
||||||
|
</span>{" "}
|
||||||
|
event type
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Sofa will automatically log movies and episodes when you
|
||||||
|
finish watching them
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
@@ -320,10 +329,6 @@ export function WebhookCard({
|
|||||||
</span>{" "}
|
</span>{" "}
|
||||||
notification type
|
notification type
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
Make sure the username above matches your Jellyfin
|
|
||||||
username
|
|
||||||
</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default async function SettingsPage() {
|
|||||||
id: conn.id,
|
id: conn.id,
|
||||||
provider: conn.provider,
|
provider: conn.provider,
|
||||||
token: conn.token,
|
token: conn.token,
|
||||||
mediaServerUsername: conn.mediaServerUsername,
|
|
||||||
enabled: conn.enabled,
|
enabled: conn.enabled,
|
||||||
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
||||||
recentEvents: events.map((e) => ({
|
recentEvents: events.map((e) => ({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { webhookConnections } 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 {
|
||||||
|
parseEmbyPayload,
|
||||||
parseJellyfinPayload,
|
parseJellyfinPayload,
|
||||||
parsePlexPayload,
|
parsePlexPayload,
|
||||||
processWebhook,
|
processWebhook,
|
||||||
@@ -36,6 +37,9 @@ export async function POST(
|
|||||||
if (connection.provider === "plex") {
|
if (connection.provider === "plex") {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
event = parsePlexPayload(formData);
|
event = parsePlexPayload(formData);
|
||||||
|
} else if (connection.provider === "emby") {
|
||||||
|
const body = await req.json();
|
||||||
|
event = parseEmbyPayload(body);
|
||||||
} else {
|
} else {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
event = parseJellyfinPayload(body);
|
event = parseJellyfinPayload(body);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`;
|
||||||
File diff suppressed because it is too large.
Load diff
+10
-12
@@ -29,18 +29,14 @@ async function getAdminSession() {
|
|||||||
// --- Webhook actions ---
|
// --- Webhook actions ---
|
||||||
|
|
||||||
export async function saveWebhookConnection(
|
export async function saveWebhookConnection(
|
||||||
provider: "plex" | "jellyfin",
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
mediaServerUsername: string,
|
|
||||||
enabled?: boolean,
|
enabled?: boolean,
|
||||||
) {
|
) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!["plex", "jellyfin"].includes(provider)) {
|
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||||
throw new Error("Invalid provider");
|
throw new Error("Invalid provider");
|
||||||
}
|
}
|
||||||
if (!mediaServerUsername?.trim()) {
|
|
||||||
throw new Error("Media server username is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
@@ -57,7 +53,6 @@ export async function saveWebhookConnection(
|
|||||||
const connection = db
|
const connection = db
|
||||||
.update(webhookConnections)
|
.update(webhookConnections)
|
||||||
.set({
|
.set({
|
||||||
mediaServerUsername: mediaServerUsername.trim(),
|
|
||||||
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
||||||
})
|
})
|
||||||
.where(eq(webhookConnections.id, existing.id))
|
.where(eq(webhookConnections.id, existing.id))
|
||||||
@@ -81,7 +76,6 @@ export async function saveWebhookConnection(
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
provider,
|
provider,
|
||||||
token,
|
token,
|
||||||
mediaServerUsername: mediaServerUsername.trim(),
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
@@ -95,10 +89,12 @@ export async function saveWebhookConnection(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
|
export async function deleteWebhookConnection(
|
||||||
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
|
) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!["plex", "jellyfin"].includes(provider)) {
|
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||||
throw new Error("Invalid provider");
|
throw new Error("Invalid provider");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +108,12 @@ export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateWebhookToken(provider: "plex" | "jellyfin") {
|
export async function regenerateWebhookToken(
|
||||||
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
|
) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!["plex", "jellyfin"].includes(provider)) {
|
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||||
throw new Error("Invalid provider");
|
throw new Error("Invalid provider");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -181,7 +181,7 @@ export const userMovieWatches = sqliteTable(
|
|||||||
.references(() => titles.id, { onDelete: "cascade" }),
|
.references(() => titles.id, { onDelete: "cascade" }),
|
||||||
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
||||||
source: text("source", {
|
source: text("source", {
|
||||||
enum: ["manual", "import", "plex", "jellyfin"],
|
enum: ["manual", "import", "plex", "jellyfin", "emby"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("manual"),
|
.default("manual"),
|
||||||
@@ -207,7 +207,7 @@ export const userEpisodeWatches = sqliteTable(
|
|||||||
.references(() => episodes.id, { onDelete: "cascade" }),
|
.references(() => episodes.id, { onDelete: "cascade" }),
|
||||||
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
|
||||||
source: text("source", {
|
source: text("source", {
|
||||||
enum: ["manual", "import", "plex", "jellyfin"],
|
enum: ["manual", "import", "plex", "jellyfin", "emby"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("manual"),
|
.default("manual"),
|
||||||
@@ -297,9 +297,10 @@ export const webhookConnections = sqliteTable(
|
|||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
provider: text("provider", { enum: ["plex", "jellyfin"] }).notNull(),
|
provider: text("provider", {
|
||||||
|
enum: ["plex", "jellyfin", "emby"],
|
||||||
|
}).notNull(),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
mediaServerUsername: text("mediaServerUsername").notNull(),
|
|
||||||
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" }),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function setTitleStatus(
|
|||||||
titleId: string,
|
titleId: string,
|
||||||
status: "watchlist" | "in_progress" | "completed",
|
status: "watchlist" | "in_progress" | "completed",
|
||||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
|
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
|
||||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.insert(userTitleStatus)
|
db.insert(userTitleStatus)
|
||||||
@@ -41,7 +41,7 @@ export function removeTitleStatus(userId: string, titleId: string) {
|
|||||||
export function logMovieWatch(
|
export function logMovieWatch(
|
||||||
userId: string,
|
userId: string,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.insert(userMovieWatches)
|
db.insert(userMovieWatches)
|
||||||
@@ -70,7 +70,7 @@ export function logMovieWatch(
|
|||||||
export function logEpisodeWatch(
|
export function logEpisodeWatch(
|
||||||
userId: string,
|
userId: string,
|
||||||
episodeId: string,
|
episodeId: string,
|
||||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.insert(userEpisodeWatches)
|
db.insert(userEpisodeWatches)
|
||||||
@@ -111,7 +111,7 @@ export function logEpisodeWatch(
|
|||||||
export function markAllEpisodesWatched(
|
export function markAllEpisodesWatched(
|
||||||
userId: string,
|
userId: string,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
|
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
) {
|
) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
if (!title || title.type !== "tv") return;
|
if (!title || title.type !== "tv") return;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
|||||||
// ─── Types ──────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface WebhookEvent {
|
export interface WebhookEvent {
|
||||||
provider: "plex" | "jellyfin";
|
provider: "plex" | "jellyfin" | "emby";
|
||||||
mediaType: "movie" | "episode";
|
mediaType: "movie" | "episode";
|
||||||
title: string;
|
title: string;
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
@@ -109,6 +109,39 @@ export function parseJellyfinPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseEmbyPayload(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): WebhookEvent | null {
|
||||||
|
// Emby sends "playback.stop" or "PlaybackStop" depending on webhook plugin version
|
||||||
|
const event = body.Event as string | undefined;
|
||||||
|
if (event !== "playback.stop" && event !== "PlaybackStop") return null;
|
||||||
|
if (body.PlayedToCompletion !== true) return null;
|
||||||
|
|
||||||
|
const item = body.Item as Record<string, unknown> | undefined;
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const itemType = item.Type as string | undefined;
|
||||||
|
const isMovie = itemType === "Movie";
|
||||||
|
const isEpisode = itemType === "Episode";
|
||||||
|
if (!isMovie && !isEpisode) return null;
|
||||||
|
|
||||||
|
const providerIds = (item.ProviderIds ?? {}) as Record<string, string>;
|
||||||
|
const tmdbRaw = providerIds.Tmdb;
|
||||||
|
const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "emby",
|
||||||
|
mediaType: isMovie ? "movie" : "episode",
|
||||||
|
title: (item.Name ?? "") as string,
|
||||||
|
tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined,
|
||||||
|
imdbId: providerIds.Imdb || undefined,
|
||||||
|
tvdbId: providerIds.Tvdb || undefined,
|
||||||
|
seasonNumber: item.ParentIndexNumber as number | undefined,
|
||||||
|
episodeNumber: item.IndexNumber as number | undefined,
|
||||||
|
showTitle: (item.SeriesName ?? item.ShowName) as string | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Title Resolution ───────────────────────────────────────────────
|
// ─── Title Resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
|
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
|
||||||
@@ -240,7 +273,12 @@ function logEvent(
|
|||||||
db.insert(webhookEventLog)
|
db.insert(webhookEventLog)
|
||||||
.values({
|
.values({
|
||||||
connectionId,
|
connectionId,
|
||||||
eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop",
|
eventType:
|
||||||
|
event?.provider === "plex"
|
||||||
|
? "media.scrobble"
|
||||||
|
: event?.provider === "emby"
|
||||||
|
? "playback.stop"
|
||||||
|
: "PlaybackStop",
|
||||||
mediaType: event?.mediaType ?? null,
|
mediaType: event?.mediaType ?? null,
|
||||||
mediaTitle: event?.title ?? null,
|
mediaTitle: event?.title ?? null,
|
||||||
status,
|
status,
|
||||||
@@ -260,7 +298,7 @@ function logEvent(
|
|||||||
export async function processWebhook(
|
export async function processWebhook(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
provider: "plex" | "jellyfin",
|
provider: "plex" | "jellyfin" | "emby",
|
||||||
event: WebhookEvent,
|
event: WebhookEvent,
|
||||||
): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
|
): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user