Files
netfelix-audio-fix/server/services/mqtt.ts
Felix Förtsch a27e4f4025
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
close the jellyfin ping-pong via mqtt webhook subscriber
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.
2026-04-14 08:26:42 +02:00

160 lines
4.6 KiB
TypeScript

import mqtt, { type MqttClient } from "mqtt";
import { getConfig } from "../db/index";
import { log, error as logError, warn } from "../lib/log";
import { handleWebhookMessage } from "./webhook";
export type MqttStatus = "connected" | "disconnected" | "error" | "not_configured";
interface MqttConfig {
url: string;
topic: string;
username: string;
password: string;
}
let client: MqttClient | null = null;
let currentStatus: MqttStatus = "not_configured";
let currentError: string | null = null;
const statusListeners = new Set<(status: MqttStatus, error: string | null) => void>();
export function getMqttStatus(): { status: MqttStatus; error: string | null } {
return { status: currentStatus, error: currentError };
}
export function onMqttStatus(fn: (status: MqttStatus, error: string | null) => void): () => void {
statusListeners.add(fn);
return () => {
statusListeners.delete(fn);
};
}
function setStatus(next: MqttStatus, err: string | null = null): void {
currentStatus = next;
currentError = err;
for (const l of statusListeners) l(next, err);
}
function readConfig(): MqttConfig | null {
const url = getConfig("mqtt_url") ?? "";
if (!url) return null;
return {
url,
topic: getConfig("mqtt_topic") ?? "jellyfin/events",
username: getConfig("mqtt_username") ?? "",
password: getConfig("mqtt_password") ?? "",
};
}
/**
* Connect to the configured MQTT broker and subscribe to the webhook topic.
* Safe to call repeatedly: an existing client is torn down first. When no
* broker is configured, status is set to 'not_configured' and the call is
* a no-op.
*/
export async function startMqttClient(): Promise<void> {
await stopMqttClient();
const cfg = readConfig();
if (!cfg) {
setStatus("not_configured");
return;
}
log(`MQTT: connecting to ${cfg.url} (topic=${cfg.topic})`);
const c = mqtt.connect(cfg.url, {
username: cfg.username || undefined,
password: cfg.password || undefined,
reconnectPeriod: 5000,
connectTimeout: 15_000,
clientId: `netfelix-audio-fix-${Math.random().toString(16).slice(2, 10)}`,
});
client = c;
c.on("connect", () => {
c.subscribe(cfg.topic, { qos: 0 }, (err) => {
if (err) {
logError(`MQTT subscribe to ${cfg.topic} failed:`, err);
setStatus("error", String(err));
return;
}
log(`MQTT: connected, subscribed to ${cfg.topic}`);
setStatus("connected");
});
});
c.on("reconnect", () => {
setStatus("disconnected", "reconnecting");
});
c.on("close", () => {
setStatus("disconnected", null);
});
c.on("error", (err) => {
warn(`MQTT error: ${String(err)}`);
setStatus("error", String(err));
});
c.on("message", (_topic, payload) => {
const text = payload.toString("utf8");
// Best-effort: the handler owns its own error handling. Don't let a
// single malformed message tear the subscriber down.
handleWebhookMessage(text).catch((err) => logError("webhook handler threw:", err));
});
}
export async function stopMqttClient(): Promise<void> {
if (!client) return;
const c = client;
client = null;
await new Promise<void>((resolve) => {
c.end(false, {}, () => resolve());
});
setStatus("not_configured");
}
/**
* 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.
*/
export async function testMqttConnection(
cfg: MqttConfig,
timeoutMs = 30_000,
): Promise<{ connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }> {
return new Promise((resolve) => {
const c = mqtt.connect(cfg.url, {
username: cfg.username || undefined,
password: cfg.password || undefined,
reconnectPeriod: 0,
connectTimeout: 10_000,
clientId: `netfelix-audio-fix-test-${Math.random().toString(16).slice(2, 10)}`,
});
let settled = false;
const done = (result: { connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }) => {
if (settled) return;
settled = true;
c.end(true);
resolve(result);
};
c.on("connect", () => {
c.subscribe(`${cfg.topic}`, { qos: 0 }, (err) => {
if (err) {
done({ connected: true, receivedMessage: false, error: String(err) });
}
});
setTimeout(() => done({ connected: true, receivedMessage: false }), timeoutMs);
});
c.on("message", (_topic, payload) => {
done({ connected: true, receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 200) });
});
c.on("error", (err) => {
done({ connected: false, receivedMessage: false, error: String(err) });
});
});
}