mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -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:
@@ -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>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -21,31 +21,23 @@ export function IntegrationsSection({
|
||||
const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
|
||||
const jellyfinConnection =
|
||||
connections.find((c) => c.provider === "jellyfin") ?? null;
|
||||
const embyConnection = connections.find((c) => c.provider === "emby") ?? null;
|
||||
|
||||
async function handleSave(provider: "plex" | "jellyfin", username: string) {
|
||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
||||
const isNew = !connections.find((c) => c.provider === provider);
|
||||
async function handleConnect(provider: "plex" | "jellyfin" | "emby") {
|
||||
const label =
|
||||
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||
try {
|
||||
const result = await saveWebhookConnection(provider, username);
|
||||
setConnections((prev) => {
|
||||
const existing = prev.find((c) => c.provider === provider);
|
||||
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`);
|
||||
const result = await saveWebhookConnection(provider);
|
||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||
toast.success(`${label} connected`);
|
||||
} catch {
|
||||
toast.error(`Failed to save ${label} connection`);
|
||||
toast.error(`Failed to connect ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(provider: "plex" | "jellyfin") {
|
||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
||||
async function handleDelete(provider: "plex" | "jellyfin" | "emby") {
|
||||
const label =
|
||||
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||
const previous = connections;
|
||||
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
||||
try {
|
||||
@@ -57,8 +49,9 @@ export function IntegrationsSection({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateToken(provider: "plex" | "jellyfin") {
|
||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
||||
async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") {
|
||||
const label =
|
||||
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||
try {
|
||||
const result = await regenerateWebhookToken(provider);
|
||||
setConnections((prev) =>
|
||||
@@ -72,19 +65,18 @@ export function IntegrationsSection({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) {
|
||||
const label = provider === "plex" ? "Plex" : "Jellyfin";
|
||||
async function handleToggle(
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
enabled: boolean,
|
||||
) {
|
||||
const label =
|
||||
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
|
||||
const previous = connections;
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
|
||||
);
|
||||
try {
|
||||
const conn = connections.find((c) => c.provider === provider);
|
||||
await saveWebhookConnection(
|
||||
provider,
|
||||
conn?.mediaServerUsername ?? "",
|
||||
enabled,
|
||||
);
|
||||
await saveWebhookConnection(provider, enabled);
|
||||
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
|
||||
} catch {
|
||||
setConnections(previous);
|
||||
@@ -104,7 +96,7 @@ export function IntegrationsSection({
|
||||
<WebhookCard
|
||||
provider="plex"
|
||||
connection={plexConnection}
|
||||
onSave={handleSave}
|
||||
onConnect={handleConnect}
|
||||
onDelete={handleDelete}
|
||||
onRegenerateToken={handleRegenerateToken}
|
||||
onToggle={handleToggle}
|
||||
@@ -112,7 +104,15 @@ export function IntegrationsSection({
|
||||
<WebhookCard
|
||||
provider="jellyfin"
|
||||
connection={jellyfinConnection}
|
||||
onSave={handleSave}
|
||||
onConnect={handleConnect}
|
||||
onDelete={handleDelete}
|
||||
onRegenerateToken={handleRegenerateToken}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
<WebhookCard
|
||||
provider="emby"
|
||||
connection={embyConnection}
|
||||
onConnect={handleConnect}
|
||||
onDelete={handleDelete}
|
||||
onRegenerateToken={handleRegenerateToken}
|
||||
onToggle={handleToggle}
|
||||
|
||||
@@ -31,13 +31,12 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { JellyfinIcon, PlexIcon } from "./icons";
|
||||
import { EmbyIcon, JellyfinIcon, PlexIcon } from "./icons";
|
||||
|
||||
export interface WebhookConnection {
|
||||
id: string;
|
||||
provider: "plex" | "jellyfin";
|
||||
provider: "plex" | "jellyfin" | "emby";
|
||||
token: string;
|
||||
mediaServerUsername: string;
|
||||
enabled: boolean;
|
||||
lastEventAt: string | null;
|
||||
recentEvents: {
|
||||
@@ -53,41 +52,41 @@ export interface WebhookConnection {
|
||||
export function WebhookCard({
|
||||
provider,
|
||||
connection,
|
||||
onSave,
|
||||
onConnect,
|
||||
onDelete,
|
||||
onRegenerateToken,
|
||||
onToggle,
|
||||
}: {
|
||||
provider: "plex" | "jellyfin";
|
||||
provider: "plex" | "jellyfin" | "emby";
|
||||
connection: WebhookConnection | null;
|
||||
onSave: (provider: "plex" | "jellyfin", username: string) => Promise<void>;
|
||||
onDelete: (provider: "plex" | "jellyfin") => Promise<void>;
|
||||
onRegenerateToken: (provider: "plex" | "jellyfin") => Promise<void>;
|
||||
onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise<void>;
|
||||
onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||
onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||
onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
|
||||
onToggle: (
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
enabled: boolean,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [username, setUsername] = useState(
|
||||
connection?.mediaServerUsername ?? "",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 label = isPlex ? "Plex" : "Jellyfin";
|
||||
const Icon = isPlex ? PlexIcon : JellyfinIcon;
|
||||
const isEmby = provider === "emby";
|
||||
const label = isPlex ? "Plex" : isEmby ? "Emby" : "Jellyfin";
|
||||
const Icon = isPlex ? PlexIcon : isEmby ? EmbyIcon : JellyfinIcon;
|
||||
|
||||
const webhookUrl = connection
|
||||
? `${window.location.origin}/api/webhooks/${connection.token}`
|
||||
: null;
|
||||
|
||||
async function handleSave() {
|
||||
if (!username.trim()) return;
|
||||
setSaving(true);
|
||||
async function handleConnect() {
|
||||
setConnecting(true);
|
||||
try {
|
||||
await onSave(provider, username.trim());
|
||||
await onConnect(provider);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,116 +146,109 @@ export function WebhookCard({
|
||||
href="https://www.plex.tv/plex-pass/"
|
||||
target="_blank"
|
||||
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>
|
||||
<IconExternalLink className="size-3 inline-block translate-y-[-1px]" />
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
</a>{" "}
|
||||
subscription.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${provider}-username`}
|
||||
className="mb-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
{label} username
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${provider}-username`}
|
||||
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()}
|
||||
{isEmby && (
|
||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
||||
<p className="text-xs leading-relaxed text-foreground/80">
|
||||
Emby webhooks require 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"
|
||||
>
|
||||
{saving ? "Saving..." : "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
username.trim() !== connection.mediaServerUsername && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !username.trim()}
|
||||
variant="outline"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<span>Emby Premiere</span>
|
||||
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
|
||||
</a>{" "}
|
||||
subscription.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 block text-xs text-muted-foreground"
|
||||
>
|
||||
Webhook URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${provider}-webhook-url`}
|
||||
readOnly
|
||||
value={webhookUrl}
|
||||
className="font-mono text-[10px] text-muted-foreground"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="text-green-400" />
|
||||
) : (
|
||||
<IconCopy />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy URL</TooltipContent>
|
||||
</Tooltip>
|
||||
{!connection ? (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
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 block text-xs text-muted-foreground"
|
||||
>
|
||||
Webhook URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${provider}-webhook-url`}
|
||||
readOnly
|
||||
value={webhookUrl}
|
||||
className="font-mono text-[10px] text-muted-foreground"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="text-green-400" />
|
||||
) : (
|
||||
<IconCopy />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy URL</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRegenerateToken(provider)}
|
||||
>
|
||||
<IconRefresh />
|
||||
Regenerate URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(provider)}
|
||||
>
|
||||
<IconTrash />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRegenerateToken(provider)}
|
||||
>
|
||||
<IconRefresh />
|
||||
Regenerate URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(provider)}
|
||||
>
|
||||
<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">
|
||||
@@ -286,9 +278,26 @@ export function WebhookCard({
|
||||
Sofa will automatically log movies and episodes when you
|
||||
finish watching them
|
||||
</li>
|
||||
</ol>
|
||||
) : isEmby ? (
|
||||
<ol className="list-inside list-decimal space-y-1.5">
|
||||
<li>
|
||||
Make sure the username above matches your Plex account
|
||||
name
|
||||
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 Stop
|
||||
</span>{" "}
|
||||
event type
|
||||
</li>
|
||||
<li>
|
||||
Sofa will automatically log movies and episodes when you
|
||||
finish watching them
|
||||
</li>
|
||||
</ol>
|
||||
) : (
|
||||
@@ -320,10 +329,6 @@ export function WebhookCard({
|
||||
</span>{" "}
|
||||
notification type
|
||||
</li>
|
||||
<li>
|
||||
Make sure the username above matches your Jellyfin
|
||||
username
|
||||
</li>
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,6 @@ export default async function SettingsPage() {
|
||||
id: conn.id,
|
||||
provider: conn.provider,
|
||||
token: conn.token,
|
||||
mediaServerUsername: conn.mediaServerUsername,
|
||||
enabled: conn.enabled,
|
||||
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
||||
recentEvents: events.map((e) => ({
|
||||
|
||||
Reference in New Issue
Block a user