close the jellyfin ping-pong via mqtt webhook subscriber
Build and Push Docker Image / build (push) Successful in 1m5s
Build and Push Docker Image / build (push) Successful in 1m5s
after ffmpeg finishes we used to block the queue on a jellyfin refresh
+ re-analyze round-trip. now we just kick jellyfin and return. a new
mqtt subscriber listens for library events from jellyfin's webhook
plugin and re-runs upsertJellyfinItem — flipping plans back to pending
when the on-disk streams still don't match, otherwise confirming done.
- execute.ts: hand-off is fire-and-forget; no more sync re-analyze
- rescan.ts: upsertJellyfinItem takes source: 'scan' | 'webhook'.
webhook-sourced rescans can reopen terminal 'done' plans when
is_noop flips back to 0; scan-sourced rescans still treat done as
terminal (keeps the dup-job fix from a06ab34 intact).
- mqtt.ts: long-lived client, auto-reconnect, status feed for UI badge
- webhook.ts: pure processWebhookEvent(db, deps) handler + 5s dedupe
map to kill jellyfin's burst re-fires during library scans
- settings: /api/settings/mqtt{,/status,/test} + /api/settings/
jellyfin/webhook-plugin (checks if the plugin is installed)
- ui: new Settings section with broker form, test button, copy-paste
setup panel for the Jellyfin plugin template. MQTT status badge on
the scan page.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface MqttStatus {
|
||||
status: "connected" | "disconnected" | "error" | "not_configured";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
connected: boolean;
|
||||
receivedMessage: boolean;
|
||||
error?: string;
|
||||
samplePayload?: string;
|
||||
}
|
||||
|
||||
interface WebhookPluginInfo {
|
||||
ok: boolean;
|
||||
installed?: boolean;
|
||||
plugin?: { Name?: string; Version?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const HANDLEBARS_TEMPLATE = `{
|
||||
"event": "{{NotificationType}}",
|
||||
"itemId": "{{ItemId}}",
|
||||
"itemType": "{{ItemType}}"
|
||||
}`;
|
||||
|
||||
function CopyableValue({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
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>
|
||||
<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 ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; locked: Set<string> }) {
|
||||
const [url, setUrl] = useState(cfg.mqtt_url ?? "");
|
||||
const [topic, setTopic] = useState(cfg.mqtt_topic || "jellyfin/events");
|
||||
const [username, setUsername] = useState(cfg.mqtt_username ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [status, setStatus] = useState<MqttStatus>({ status: "not_configured", error: null });
|
||||
const [plugin, setPlugin] = useState<WebhookPluginInfo | null>(null);
|
||||
|
||||
const allLocked =
|
||||
locked.has("mqtt_url") && locked.has("mqtt_topic") && locked.has("mqtt_username") && locked.has("mqtt_password");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await api.get<MqttStatus>("/api/settings/mqtt/status");
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<WebhookPluginInfo>("/api/settings/jellyfin/webhook-plugin")
|
||||
.then(setPlugin)
|
||||
.catch((err) => setPlugin({ ok: false, error: String(err) }));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
try {
|
||||
await api.post("/api/settings/mqtt", { url, topic, username, password });
|
||||
setSavedMsg(password ? "Saved." : "Saved (password unchanged).");
|
||||
setPassword("");
|
||||
setTimeout(() => setSavedMsg(""), 2500);
|
||||
} catch (e) {
|
||||
setSavedMsg(`Failed: ${String(e)}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
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) });
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const statusColor =
|
||||
status.status === "connected"
|
||||
? "text-green-700 bg-green-50 border-green-300"
|
||||
: status.status === "error"
|
||||
? "text-red-700 bg-red-50 border-red-300"
|
||||
: status.status === "disconnected"
|
||||
? "text-amber-700 bg-amber-50 border-amber-300"
|
||||
: "text-gray-600 bg-gray-50 border-gray-300";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
<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 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 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>}
|
||||
</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</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.
|
||||
</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:
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<CopyableValue label="Broker URL" value={url || "mqtt://broker:1883"} />
|
||||
<CopyableValue label="Topic" value={topic || "jellyfin/events"} />
|
||||
<CopyableValue label="Events" value="Item Added, Item Updated" mono={false} />
|
||||
<CopyableValue label="Item types" value="Movie, Episode" mono={false} />
|
||||
<CopyableValue label="Template" value={HANDLEBARS_TEMPLATE} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user