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

- 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:
2026-04-14 09:35:21 +02:00
parent 9bb46ae968
commit 7b138f4346
5 changed files with 275 additions and 162 deletions

View File

@@ -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);
});

View File

@@ -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) });
});
});
}