diff --git a/package.json b/package.json index 15451f4..bfd7b57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/settings.ts b/server/api/settings.ts index 755c5e4..c8ee4b2 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -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); }); diff --git a/server/services/mqtt.ts b/server/services/mqtt.ts index dd1244f..5a16a7b 100644 --- a/server/services/mqtt.ts +++ b/server/services/mqtt.ts @@ -113,16 +113,33 @@ export async function stopMqttClient(): Promise { 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 `/#`, 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 { 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) => { 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) }); }); }); } diff --git a/src/features/settings/MqttSection.tsx b/src/features/settings/MqttSection.tsx index 4bc68f7..1e2cc33 100644 --- a/src/features/settings/MqttSection.tsx +++ b/src/features/settings/MqttSection.tsx @@ -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; lock const r = await api.post("/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; lock const jellyfinBase = (cfg.jellyfin_url ?? "").replace(/\/$/, "") || "http://jellyfin.lan:8096"; return ( -
+
-
- Jellyfin webhook (MQTT) -
- MQTT: {status.status} +
Jellyfin → MQTT webhook
+ {enabled && MQTT: {status.status}}

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; lock Enable MQTT integration -

- - -
- - -
-
- -
- - - {savedMsg && ✓ {savedMsg}} -
- - {testResult && ( -
- {testResult.connected && testResult.receivedMessage && ( -
- ✓ Connected and received a message. - {testResult.samplePayload && ( -
{testResult.samplePayload}
- )} -
- )} - {testResult.connected && !testResult.receivedMessage && !testResult.error && ( -
- ⚠ Connected, but no traffic in the timeout window. Trigger a library scan in Jellyfin, then retry. -
- )} - {testResult.error &&
✗ {testResult.error}
} + {!enabled && ( +
+ + {savedMsg && ✓ {savedMsg}}
)} - {/* Plugin + setup instructions */} {enabled && ( -
-
Jellyfin Webhook plugin setup
- {plugin?.ok === false &&

Couldn't reach Jellyfin: {plugin.error}

} - {plugin?.ok && !plugin.installed && ( -

- ⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "} - Dashboard → Plugins → Catalog → Webhook, restart Jellyfin, then configure an - MQTT destination with the values below. -

- )} - {plugin?.ok && plugin.installed && ( -

- ✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}. -

- )} -

- In Jellyfin → Dashboard → Plugins → Webhook, set the plugin-wide - Server Url at the top of the page, then - Add Generic Destination and fill in: -

-
-
Top of plugin page
- - -
Generic destination
- - +
+ +
+ + + {savedMsg && ✓ {savedMsg}} +
+ + {testResult && ( +
+ {/* Step 1: broker reachable */} +
+ {testResult.brokerConnected ? "✓" : "✗"} Broker reachable & credentials accepted +
+ {/* Step 2: jellyfin rescan kicked off */} + {testResult.brokerConnected && ( +
+ {testResult.jellyfinTriggered + ? `✓ Triggered Jellyfin rescan on "${testResult.itemName}"` + : "— No Jellyfin items to rescan yet (run a library scan first, then retry)"} +
+ )} + {/* Step 3: webhook landed */} + {testResult.brokerConnected && testResult.jellyfinTriggered && ( +
+ {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."} +
+ )} + {testResult.samplePayload && ( +
+									{testResult.samplePayload}
+								
+ )} + {testResult.error &&
✗ {testResult.error}
} +
+ )} + + {/* Plugin + setup instructions */} +
+
Jellyfin Webhook plugin setup
+ {plugin?.ok === false &&

Couldn't reach Jellyfin: {plugin.error}

} + {plugin?.ok && !plugin.installed && ( +

+ ⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "} + Dashboard → Plugins → Catalog → Webhook, restart Jellyfin, then configure an + MQTT destination with the values below. +

+ )} + {plugin?.ok && plugin.installed && ( +

+ ✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}. +

+ )} +

+ In Jellyfin → Dashboard → Plugins → Webhook, set the plugin-wide + Server Url at the top of the page, then + Add Generic Destination and fill in: +

+
+
Top of plugin page
+ + +
Generic destination
+ + + + + + +
MQTT settings
+ + + + + {useCredentials && ( + <> + + + + )} + + + +
Template
+ +
+

+ 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{" "} + Item Added, which is what we listen for. +

+
+ )}
); diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index bbe79f7..76b2645 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -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; + 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" && ✗ {status.error}} {status?.kind === "request-error" && ✗ {status.error}}
+ {children &&
{children}
} ); } @@ -415,7 +418,7 @@ export function SettingsPage() {

Settings

- {/* Jellyfin */} + {/* Jellyfin (with nested MQTT webhook config) */} - - {/* Jellyfin → MQTT webhook */} - + > + + {/* Radarr */}