|
|
|
|
@@ -74,7 +74,22 @@ async function copyText(text: string): Promise<boolean> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CopyableValue({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
|
|
|
|
|
/**
|
|
|
|
|
* One row of the plugin-setup checklist. `copyable=false` is for fields the
|
|
|
|
|
* user selects from a dropdown / toggles (Status, Notification Type, Use TLS,
|
|
|
|
|
* etc.) — copying their value doesn't help, so we just display it.
|
|
|
|
|
*/
|
|
|
|
|
function SetupValue({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
mono = true,
|
|
|
|
|
copyable = true,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
mono?: boolean;
|
|
|
|
|
copyable?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
|
|
|
|
|
const copy = async () => {
|
|
|
|
|
const ok = await copyText(value);
|
|
|
|
|
@@ -83,22 +98,27 @@ function CopyableValue({ label, value, mono = true }: { label: string; value: st
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-start gap-2 mb-2">
|
|
|
|
|
<div className="text-xs text-gray-500 w-24 flex-shrink-0 pt-1.5">{label}</div>
|
|
|
|
|
<div className="text-xs text-gray-500 w-28 flex-shrink-0 pt-1.5">{label}</div>
|
|
|
|
|
<pre className={`flex-1 text-xs bg-gray-50 border rounded px-2 py-1.5 overflow-x-auto ${mono ? "font-mono" : ""}`}>
|
|
|
|
|
{value}
|
|
|
|
|
</pre>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={copy}
|
|
|
|
|
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 flex-shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{copied === "ok" ? "Copied" : copied === "fail" ? "Select & ⌘C" : "Copy"}
|
|
|
|
|
</button>
|
|
|
|
|
{copyable ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={copy}
|
|
|
|
|
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 flex-shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{copied === "ok" ? "Copied" : copied === "fail" ? "Select & ⌘C" : "Copy"}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-[11px] text-gray-400 italic w-[72px] flex-shrink-0 pt-1.5 text-center">select</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; locked: Set<string> }) {
|
|
|
|
|
const [enabled, setEnabled] = useState(cfg.mqtt_enabled === "1");
|
|
|
|
|
const [url, setUrl] = useState(cfg.mqtt_url ?? "");
|
|
|
|
|
const [topic, setTopic] = useState(cfg.mqtt_topic || "jellyfin/events");
|
|
|
|
|
const [username, setUsername] = useState(cfg.mqtt_username ?? "");
|
|
|
|
|
@@ -142,7 +162,7 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setSavedMsg("");
|
|
|
|
|
try {
|
|
|
|
|
await api.post("/api/settings/mqtt", { url, topic, username, password });
|
|
|
|
|
await api.post("/api/settings/mqtt", { enabled, url, topic, username, password });
|
|
|
|
|
setSavedMsg(password ? "Saved." : "Saved (password unchanged).");
|
|
|
|
|
setPassword("");
|
|
|
|
|
setTimeout(() => setSavedMsg(""), 2500);
|
|
|
|
|
@@ -173,6 +193,10 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
|
|
|
|
? "text-amber-700 bg-amber-50 border-amber-300"
|
|
|
|
|
: "text-gray-600 bg-gray-50 border-gray-300";
|
|
|
|
|
|
|
|
|
|
const broker = parseBroker(url);
|
|
|
|
|
const useCredentials = !!(username || cfg.mqtt_password);
|
|
|
|
|
const jellyfinBase = (cfg.jellyfin_url ?? "").replace(/\/$/, "") || "http://jellyfin.lan:8096";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
@@ -186,56 +210,68 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
|
|
|
|
re-analyze the item, confirming it as done or flipping it back to pending if something didn't stick.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<label className="block text-sm text-gray-700 mb-1">
|
|
|
|
|
Broker URL
|
|
|
|
|
<Input
|
|
|
|
|
type="url"
|
|
|
|
|
value={url}
|
|
|
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
|
|
|
placeholder="mqtt://192.168.1.10:1883"
|
|
|
|
|
disabled={locked.has("mqtt_url")}
|
|
|
|
|
className="mt-0.5 max-w-sm"
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-gray-700 mb-3">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={enabled}
|
|
|
|
|
onChange={(e) => setEnabled(e.target.checked)}
|
|
|
|
|
disabled={locked.has("mqtt_enabled")}
|
|
|
|
|
/>
|
|
|
|
|
Enable MQTT integration
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
|
|
|
|
Topic
|
|
|
|
|
<Input
|
|
|
|
|
value={topic}
|
|
|
|
|
onChange={(e) => setTopic(e.target.value)}
|
|
|
|
|
placeholder="jellyfin/events"
|
|
|
|
|
disabled={locked.has("mqtt_topic")}
|
|
|
|
|
className="mt-0.5 max-w-xs"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-3 max-w-md">
|
|
|
|
|
<label className="block text-sm text-gray-700">
|
|
|
|
|
Username
|
|
|
|
|
|
|
|
|
|
<div className={enabled ? "" : "opacity-50 pointer-events-none"}>
|
|
|
|
|
<label className="block text-sm text-gray-700 mb-1">
|
|
|
|
|
Broker URL
|
|
|
|
|
<Input
|
|
|
|
|
value={username}
|
|
|
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
|
|
|
placeholder="(optional)"
|
|
|
|
|
disabled={locked.has("mqtt_username")}
|
|
|
|
|
className="mt-0.5"
|
|
|
|
|
type="url"
|
|
|
|
|
value={url}
|
|
|
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
|
|
|
placeholder="mqtt://192.168.1.10:1883"
|
|
|
|
|
disabled={locked.has("mqtt_url")}
|
|
|
|
|
className="mt-0.5 max-w-sm"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block text-sm text-gray-700">
|
|
|
|
|
Password
|
|
|
|
|
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
|
|
|
|
Topic
|
|
|
|
|
<Input
|
|
|
|
|
type="password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder={cfg.mqtt_password ? "(unchanged)" : "(optional)"}
|
|
|
|
|
disabled={locked.has("mqtt_password")}
|
|
|
|
|
className="mt-0.5"
|
|
|
|
|
value={topic}
|
|
|
|
|
onChange={(e) => setTopic(e.target.value)}
|
|
|
|
|
placeholder="jellyfin/events"
|
|
|
|
|
disabled={locked.has("mqtt_topic")}
|
|
|
|
|
className="mt-0.5 max-w-xs"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-3 max-w-md">
|
|
|
|
|
<label className="block text-sm text-gray-700">
|
|
|
|
|
Username
|
|
|
|
|
<Input
|
|
|
|
|
value={username}
|
|
|
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
|
|
|
placeholder="(optional)"
|
|
|
|
|
disabled={locked.has("mqtt_username")}
|
|
|
|
|
className="mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block text-sm text-gray-700">
|
|
|
|
|
Password
|
|
|
|
|
<Input
|
|
|
|
|
type="password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder={cfg.mqtt_password ? "(unchanged)" : "(optional)"}
|
|
|
|
|
disabled={locked.has("mqtt_password")}
|
|
|
|
|
className="mt-0.5"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 mt-3">
|
|
|
|
|
<Button onClick={save} disabled={saving || allLocked}>
|
|
|
|
|
{saving ? "Saving…" : "Save"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={runTest} disabled={testing || !url}>
|
|
|
|
|
<Button variant="secondary" onClick={runTest} disabled={testing || !url || !enabled}>
|
|
|
|
|
{testing ? "Testing…" : "Test connection"}
|
|
|
|
|
</Button>
|
|
|
|
|
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
|
|
|
|
@@ -261,60 +297,71 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Plugin + setup instructions */}
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
|
|
|
<div className="text-sm font-medium mb-2">Jellyfin Webhook plugin</div>
|
|
|
|
|
{plugin?.ok === false && <p className="text-xs text-red-600">Couldn't reach Jellyfin: {plugin.error}</p>}
|
|
|
|
|
{plugin?.ok && !plugin.installed && (
|
|
|
|
|
<p className="text-xs text-amber-700">
|
|
|
|
|
⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "}
|
|
|
|
|
<span className="font-mono">Jellyfin → Dashboard → Plugins → Catalog → Webhook</span>, restart Jellyfin, then
|
|
|
|
|
configure an MQTT destination with the values below.
|
|
|
|
|
{enabled && (
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
|
|
|
<div className="text-sm font-medium mb-2">Jellyfin Webhook plugin setup</div>
|
|
|
|
|
{plugin?.ok === false && <p className="text-xs text-red-600">Couldn't reach Jellyfin: {plugin.error}</p>}
|
|
|
|
|
{plugin?.ok && !plugin.installed && (
|
|
|
|
|
<p className="text-xs text-amber-700">
|
|
|
|
|
⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "}
|
|
|
|
|
<span className="font-mono">Dashboard → Plugins → Catalog → Webhook</span>, restart Jellyfin, then configure an
|
|
|
|
|
MQTT destination with the values below.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{plugin?.ok && plugin.installed && (
|
|
|
|
|
<p className="text-xs text-green-700 mb-2">
|
|
|
|
|
✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1 mb-3">
|
|
|
|
|
In Jellyfin → <span className="font-mono">Dashboard → Plugins → Webhook</span>, set the plugin-wide
|
|
|
|
|
<span className="font-mono"> Server Url</span> at the top of the page, then
|
|
|
|
|
<span className="font-mono"> Add Generic Destination</span> and fill in:
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{plugin?.ok && plugin.installed && (
|
|
|
|
|
<p className="text-xs text-green-700 mb-2">
|
|
|
|
|
✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}. Add an MQTT destination with:
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<div className="text-[11px] font-semibold text-gray-500 uppercase mb-1">Top of plugin page</div>
|
|
|
|
|
<SetupValue label="Server Url" value={jellyfinBase} />
|
|
|
|
|
|
|
|
|
|
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">Generic destination</div>
|
|
|
|
|
<SetupValue label="Webhook Name" value="Audio Fix" mono={false} />
|
|
|
|
|
<SetupValue
|
|
|
|
|
label="Webhook Url"
|
|
|
|
|
value={`${broker.useTls ? "mqtts" : "mqtt"}://${broker.host || "broker.lan"}:${broker.port}`}
|
|
|
|
|
/>
|
|
|
|
|
<SetupValue label="Status" value="Enabled" mono={false} copyable={false} />
|
|
|
|
|
<SetupValue label="Notification Type" value="Item Added" mono={false} copyable={false} />
|
|
|
|
|
<SetupValue label="Item Type" value="Movies, Episodes" mono={false} copyable={false} />
|
|
|
|
|
|
|
|
|
|
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">MQTT settings</div>
|
|
|
|
|
<SetupValue label="MQTT Server" value={broker.host || "broker.lan"} />
|
|
|
|
|
<SetupValue label="MQTT Port" value={broker.port} />
|
|
|
|
|
<SetupValue label="Use TLS" value={broker.useTls ? "Enabled" : "Disabled"} mono={false} copyable={false} />
|
|
|
|
|
<SetupValue
|
|
|
|
|
label="Use Credentials"
|
|
|
|
|
value={useCredentials ? "Enabled" : "Disabled"}
|
|
|
|
|
mono={false}
|
|
|
|
|
copyable={false}
|
|
|
|
|
/>
|
|
|
|
|
{useCredentials && (
|
|
|
|
|
<>
|
|
|
|
|
<SetupValue label="Username" value={username || "(same as above)"} />
|
|
|
|
|
<SetupValue label="Password" value={cfg.mqtt_password ? "(same as above)" : ""} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<SetupValue label="Topic" value={topic || "jellyfin/events"} />
|
|
|
|
|
<SetupValue label="Quality of Service" value="At most once (QoS 0)" mono={false} copyable={false} />
|
|
|
|
|
|
|
|
|
|
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">Template</div>
|
|
|
|
|
<SetupValue label="Template" value={HANDLEBARS_TEMPLATE} />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[11px] text-gray-400 mt-3">
|
|
|
|
|
Notes: "Server Url" is Jellyfin's own base URL (used for rendered links in notification templates). "Webhook Url"
|
|
|
|
|
is required by the form even for MQTT destinations, which ignore it — we fill it with the broker URL so validation
|
|
|
|
|
passes. Jellyfin doesn't expose an "Item Updated" event — any file change fires{" "}
|
|
|
|
|
<span className="font-mono">Item Added</span>, which is what we listen for.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<p className="text-xs text-gray-500 mt-1 mb-2">
|
|
|
|
|
Open Jellyfin → <span className="font-mono">Dashboard → Plugins → Webhook → Add Generic Destination →</span> MQTT
|
|
|
|
|
tab, and fill in:
|
|
|
|
|
</p>
|
|
|
|
|
{(() => {
|
|
|
|
|
const broker = parseBroker(url);
|
|
|
|
|
const useCredentials = !!(username || cfg.mqtt_password);
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<CopyableValue label="Webhook Name" value="Audio Fix" mono={false} />
|
|
|
|
|
<CopyableValue
|
|
|
|
|
label="Webhook Url"
|
|
|
|
|
value={`${broker.useTls ? "mqtts" : "mqtt"}://${broker.host || "broker.lan"}:${broker.port}`}
|
|
|
|
|
/>
|
|
|
|
|
<CopyableValue label="Status" value="Enabled" mono={false} />
|
|
|
|
|
<CopyableValue label="Notification Type" value="Item Added" mono={false} />
|
|
|
|
|
<CopyableValue label="Item Type" value="Movies, Episodes" mono={false} />
|
|
|
|
|
<CopyableValue label="MQTT Server" value={broker.host || "broker.lan"} />
|
|
|
|
|
<CopyableValue label="MQTT Port" value={broker.port} />
|
|
|
|
|
<CopyableValue label="Use TLS" value={broker.useTls ? "Enabled" : "Disabled"} mono={false} />
|
|
|
|
|
<CopyableValue label="Use Credentials" value={useCredentials ? "Enabled" : "Disabled"} mono={false} />
|
|
|
|
|
{useCredentials && (
|
|
|
|
|
<>
|
|
|
|
|
<CopyableValue label="Username" value={username || "(same as above)"} />
|
|
|
|
|
<CopyableValue label="Password" value={cfg.mqtt_password ? "(same as above)" : ""} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<CopyableValue label="Topic" value={topic || "jellyfin/events"} />
|
|
|
|
|
<CopyableValue label="Quality of Service" value="At most once (QoS 0)" mono={false} />
|
|
|
|
|
<CopyableValue label="Template" value={HANDLEBARS_TEMPLATE} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
<p className="text-[11px] text-gray-400 mt-2">
|
|
|
|
|
Notes: the plugin's "Webhook Url" field is for HTTP destinations; MQTT ignores it, but the form won't save empty,
|
|
|
|
|
so we put the broker URL there as a placeholder. Jellyfin doesn't expose an "Item Updated" event — any file
|
|
|
|
|
change fires <span className="font-mono">Item Added</span>, which is what we listen for.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|