mqtt webhook: nest under jellyfin card, strict enable gating, end-to-end test
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m29s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m29s
- MqttSection now renders as a nested block inside the Jellyfin ConnSection instead of its own card; ConnSection grew a children slot - when the enable checkbox is off, broker/topic/credentials inputs and the whole plugin setup panel are hidden; only the toggle + a small save button remain - 'Test Connection' became 'Test end-to-end': connects to the broker, subscribes, picks a random scanned movie/episode, asks jellyfin to refresh it, and waits for a matching webhook message. the UI walks through all three steps (broker reachable → jellyfin rescan triggered → webhook received) with per-step success/failure so a broken plugin config is obvious
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.04.14.5",
|
||||
"version": "2026.04.14.6",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
|
||||
import { getUsers, refreshItem, testConnection as testJellyfin } from "../services/jellyfin";
|
||||
import { getMqttStatus, startMqttClient, testMqttConnection } from "../services/mqtt";
|
||||
import { testConnection as testRadarr } from "../services/radarr";
|
||||
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
|
||||
@@ -145,7 +145,31 @@ app.post("/mqtt/test", async (c) => {
|
||||
const topic = (body.topic ?? "jellyfin/events").trim() || "jellyfin/events";
|
||||
const password = body.password || getConfig("mqtt_password") || "";
|
||||
|
||||
const result = await testMqttConnection({ url, topic, username: (body.username ?? "").trim(), password }, 15_000);
|
||||
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
|
||||
const jellyfinApiKey = getConfig("jellyfin_api_key") ?? "";
|
||||
const jellyfinUserId = getConfig("jellyfin_user_id") ?? "";
|
||||
const jfCfg = { url: jellyfinUrl, apiKey: jellyfinApiKey, userId: jellyfinUserId };
|
||||
|
||||
const triggerRefresh = async (): Promise<{ itemId: string; itemName: string } | null> => {
|
||||
if (!jellyfinUrl || !jellyfinApiKey) return null;
|
||||
// Grab any scanned movie/episode as the trigger target. Random ordering
|
||||
// so repeated tests don't keep hammering the same item.
|
||||
const row = getDb()
|
||||
.prepare("SELECT jellyfin_id, name FROM media_items WHERE type IN ('Movie', 'Episode') ORDER BY RANDOM() LIMIT 1")
|
||||
.get() as { jellyfin_id: string; name: string } | undefined;
|
||||
if (!row) return null;
|
||||
// Fire-and-poll: refreshItem blocks until Jellyfin finishes, which we
|
||||
// don't want here — the webhook fires during the refresh itself.
|
||||
// Kick it off in the background; the mqtt subscriber catches the event.
|
||||
refreshItem(jfCfg, row.jellyfin_id, 2_000).catch(() => {});
|
||||
return { itemId: row.jellyfin_id, itemName: row.name };
|
||||
};
|
||||
|
||||
const result = await testMqttConnection(
|
||||
{ url, topic, username: (body.username ?? "").trim(), password },
|
||||
triggerRefresh,
|
||||
30_000,
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,16 +113,33 @@ export async function stopMqttClient(): Promise<void> {
|
||||
setStatus("not_configured");
|
||||
}
|
||||
|
||||
export interface MqttTestResult {
|
||||
brokerConnected: boolean;
|
||||
jellyfinTriggered: boolean;
|
||||
receivedMessage: boolean;
|
||||
itemName?: string;
|
||||
expectedItemId?: string;
|
||||
samplePayload?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a candidate MQTT configuration without touching the running client.
|
||||
* Connects, subscribes to `<topic>/#`, waits up to `timeoutMs` for any
|
||||
* message, then disconnects. Returns whether the connection succeeded and
|
||||
* whether any traffic arrived.
|
||||
* End-to-end test of the MQTT loop: connect to the broker, subscribe to the
|
||||
* topic, ask Jellyfin to refresh a known item, and wait for the plugin to
|
||||
* publish a matching event. A pass proves the whole chain is wired up —
|
||||
* broker creds, Jellyfin webhook plugin config, and network reachability
|
||||
* between Jellyfin and broker.
|
||||
*
|
||||
* `triggerRefresh` is async and returns the Jellyfin item id we're waiting
|
||||
* for (so we can match only messages about that item and ignore unrelated
|
||||
* traffic). When null, we fall back to "any message on the topic" mode —
|
||||
* useful before the library is scanned.
|
||||
*/
|
||||
export async function testMqttConnection(
|
||||
cfg: MqttConfig,
|
||||
triggerRefresh: () => Promise<{ itemId: string; itemName: string } | null>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }> {
|
||||
): Promise<MqttTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
@@ -133,28 +150,63 @@ export async function testMqttConnection(
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const done = (result: { connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }) => {
|
||||
let expectedItemId: string | null = null;
|
||||
let itemName: string | undefined;
|
||||
let jellyfinTriggered = false;
|
||||
let brokerConnected = false;
|
||||
const done = (result: Omit<MqttTestResult, "expectedItemId" | "jellyfinTriggered" | "brokerConnected">) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
c.end(true);
|
||||
resolve(result);
|
||||
resolve({
|
||||
brokerConnected,
|
||||
jellyfinTriggered,
|
||||
expectedItemId: expectedItemId ?? undefined,
|
||||
itemName,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
c.on("connect", () => {
|
||||
c.subscribe(`${cfg.topic}`, { qos: 0 }, (err) => {
|
||||
brokerConnected = true;
|
||||
c.subscribe(cfg.topic, { qos: 0 }, async (err) => {
|
||||
if (err) {
|
||||
done({ connected: true, receivedMessage: false, error: String(err) });
|
||||
done({ receivedMessage: false, error: `subscribe: ${String(err)}` });
|
||||
return;
|
||||
}
|
||||
// Subscribed. Trigger the Jellyfin refresh so the webhook has
|
||||
// something concrete to publish.
|
||||
try {
|
||||
const trigger = await triggerRefresh();
|
||||
if (trigger) {
|
||||
expectedItemId = trigger.itemId;
|
||||
itemName = trigger.itemName;
|
||||
jellyfinTriggered = true;
|
||||
}
|
||||
} catch (triggerErr) {
|
||||
done({ receivedMessage: false, error: `jellyfin trigger: ${String(triggerErr)}` });
|
||||
return;
|
||||
}
|
||||
});
|
||||
setTimeout(() => done({ connected: true, receivedMessage: false }), timeoutMs);
|
||||
setTimeout(() => done({ receivedMessage: false }), timeoutMs);
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
done({ connected: true, receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 200) });
|
||||
const raw = payload.toString("utf8");
|
||||
// If we're waiting for a specific itemId, only accept matches.
|
||||
if (expectedItemId) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { itemId?: string };
|
||||
if (parsed.itemId !== expectedItemId) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
done({ receivedMessage: true, samplePayload: raw.slice(0, 400) });
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
done({ connected: false, receivedMessage: false, error: String(err) });
|
||||
done({ receivedMessage: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ interface MqttStatus {
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
connected: boolean;
|
||||
brokerConnected: boolean;
|
||||
jellyfinTriggered: boolean;
|
||||
receivedMessage: boolean;
|
||||
error?: string;
|
||||
expectedItemId?: string;
|
||||
itemName?: string;
|
||||
samplePayload?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WebhookPluginInfo {
|
||||
@@ -179,7 +182,12 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
||||
const r = await api.post<TestResult>("/api/settings/mqtt/test", { url, topic, username, password });
|
||||
setTestResult(r);
|
||||
} catch (e) {
|
||||
setTestResult({ connected: false, receivedMessage: false, error: String(e) });
|
||||
setTestResult({
|
||||
brokerConnected: false,
|
||||
jellyfinTriggered: false,
|
||||
receivedMessage: false,
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
@@ -198,12 +206,10 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
||||
const jellyfinBase = (cfg.jellyfin_url ?? "").replace(/\/$/, "") || "http://jellyfin.lan:8096";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-semibold text-sm">
|
||||
Jellyfin webhook <span className="text-gray-400 font-normal">(MQTT)</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>
|
||||
<div className="font-semibold text-sm">Jellyfin → MQTT webhook</div>
|
||||
{enabled && <span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-3 mt-0">
|
||||
Close the loop: once Jellyfin finishes its post-ffmpeg rescan, it publishes an event to your MQTT broker. We
|
||||
@@ -220,147 +226,176 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
|
||||
Enable MQTT integration
|
||||
</label>
|
||||
|
||||
<div className={enabled ? "" : "opacity-50 pointer-events-none"}>
|
||||
<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>
|
||||
<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
|
||||
<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 || !enabled}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-3 text-sm">
|
||||
{testResult.connected && testResult.receivedMessage && (
|
||||
<div className="text-green-700">
|
||||
✓ Connected and received a message.
|
||||
{testResult.samplePayload && (
|
||||
<pre className="mt-1 text-xs bg-gray-50 border rounded px-2 py-1 font-mono">{testResult.samplePayload}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{testResult.connected && !testResult.receivedMessage && !testResult.error && (
|
||||
<div className="text-amber-700">
|
||||
⚠ Connected, but no traffic in the timeout window. Trigger a library scan in Jellyfin, then retry.
|
||||
</div>
|
||||
)}
|
||||
{testResult.error && <div className="text-red-700">✗ {testResult.error}</div>}
|
||||
{!enabled && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin + setup instructions */}
|
||||
{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>
|
||||
<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}`}
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
<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}
|
||||
</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"
|
||||
/>
|
||||
{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} />
|
||||
</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>
|
||||
<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>
|
||||
</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}>
|
||||
{testing ? "Testing…" : "Test end-to-end"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-3 text-sm space-y-1">
|
||||
{/* Step 1: broker reachable */}
|
||||
<div className={testResult.brokerConnected ? "text-green-700" : "text-red-700"}>
|
||||
{testResult.brokerConnected ? "✓" : "✗"} Broker reachable & credentials accepted
|
||||
</div>
|
||||
{/* Step 2: jellyfin rescan kicked off */}
|
||||
{testResult.brokerConnected && (
|
||||
<div
|
||||
className={
|
||||
testResult.jellyfinTriggered
|
||||
? "text-green-700"
|
||||
: testResult.error?.startsWith("jellyfin")
|
||||
? "text-red-700"
|
||||
: "text-gray-500"
|
||||
}
|
||||
>
|
||||
{testResult.jellyfinTriggered
|
||||
? `✓ Triggered Jellyfin rescan on "${testResult.itemName}"`
|
||||
: "— No Jellyfin items to rescan yet (run a library scan first, then retry)"}
|
||||
</div>
|
||||
)}
|
||||
{/* Step 3: webhook landed */}
|
||||
{testResult.brokerConnected && testResult.jellyfinTriggered && (
|
||||
<div className={testResult.receivedMessage ? "text-green-700" : "text-amber-700"}>
|
||||
{testResult.receivedMessage
|
||||
? `✓ Received matching webhook on topic "${topic || "jellyfin/events"}"`
|
||||
: "⚠ No webhook message arrived within the timeout. Check the Jellyfin Webhook plugin destination: events include Item Added, Item Type filter covers Movies/Episodes, MQTT server/topic match the values shown below, and the Jellyfin host can reach the broker."}
|
||||
</div>
|
||||
)}
|
||||
{testResult.samplePayload && (
|
||||
<pre className="text-xs bg-gray-50 border rounded px-2 py-1 font-mono overflow-x-auto">
|
||||
{testResult.samplePayload}
|
||||
</pre>
|
||||
)}
|
||||
{testResult.error && <div className="text-red-700">✗ {testResult.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 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>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -180,6 +180,7 @@ function ConnSection({
|
||||
apiKey: apiKeyProp,
|
||||
urlPlaceholder,
|
||||
onSave,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
@@ -189,6 +190,7 @@ function ConnSection({
|
||||
apiKey: string;
|
||||
urlPlaceholder: string;
|
||||
onSave: (url: string, apiKey: string) => Promise<SaveResult>;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [url, setUrl] = useState(cfg[urlKey] ?? "");
|
||||
const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
|
||||
@@ -251,6 +253,7 @@ function ConnSection({
|
||||
{status?.kind === "validation-error" && <span className="text-sm text-red-600">✗ {status.error}</span>}
|
||||
{status?.kind === "request-error" && <span className="text-sm text-red-600">✗ {status.error}</span>}
|
||||
</div>
|
||||
{children && <div className="mt-5 pt-4 border-t border-gray-200">{children}</div>}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -415,7 +418,7 @@ export function SettingsPage() {
|
||||
<h1 className="text-xl font-bold m-0">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Jellyfin */}
|
||||
{/* Jellyfin (with nested MQTT webhook config) */}
|
||||
<ConnSection
|
||||
title="Jellyfin"
|
||||
urlKey="jellyfin_url"
|
||||
@@ -424,10 +427,9 @@ export function SettingsPage() {
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveJellyfin}
|
||||
/>
|
||||
|
||||
{/* Jellyfin → MQTT webhook */}
|
||||
<MqttSection cfg={cfg} locked={locked} />
|
||||
>
|
||||
<MqttSection cfg={cfg} locked={locked} />
|
||||
</ConnSection>
|
||||
|
||||
{/* Radarr */}
|
||||
<ConnSection
|
||||
|
||||
Reference in New Issue
Block a user