close the jellyfin ping-pong via mqtt webhook subscriber
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
All checks were successful
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:
@@ -4,9 +4,7 @@ import { stream } from "hono/streaming";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { predictExtractedFiles } from "../services/ffmpeg";
|
||||
import { getItem, refreshItem } from "../services/jellyfin";
|
||||
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
|
||||
import { upsertJellyfinItem } from "../services/rescan";
|
||||
import { refreshItem } from "../services/jellyfin";
|
||||
import {
|
||||
getScheduleConfig,
|
||||
isInProcessWindow,
|
||||
@@ -15,28 +13,15 @@ import {
|
||||
sleepBetweenJobs,
|
||||
waitForProcessWindow,
|
||||
} from "../services/scheduler";
|
||||
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
|
||||
import { verifyDesiredState } from "../services/verify";
|
||||
import type { Job, MediaItem, MediaStream } from "../types";
|
||||
|
||||
function parseLanguageList(raw: string | null | undefined, fallback: string[]): string[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a job finishes successfully, ask Jellyfin to re-scan the file,
|
||||
* fetch the fresh item, and upsert it — including running analyzeItem so the
|
||||
* review plan reflects whether the file is now fully conformant. If is_noop
|
||||
* is true on the refreshed streams, the plan lands in `done`; otherwise it
|
||||
* flips back to `pending` so the user sees what still needs attention.
|
||||
* Fire-and-forget hand-off to Jellyfin after a successful job: ask Jellyfin
|
||||
* to re-scan the file and return immediately. The MQTT webhook subscriber
|
||||
* closes the loop once Jellyfin finishes its rescan and publishes an event.
|
||||
*/
|
||||
async function refreshItemFromJellyfin(itemId: number): Promise<void> {
|
||||
async function handOffToJellyfin(itemId: number): Promise<void> {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT jellyfin_id FROM media_items WHERE id = ?").get(itemId) as
|
||||
| { jellyfin_id: string }
|
||||
@@ -52,34 +37,6 @@ async function refreshItemFromJellyfin(itemId: number): Promise<void> {
|
||||
} catch (err) {
|
||||
warn(`Jellyfin refresh for item ${itemId} failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const fresh = await getItem(jellyfinCfg, row.jellyfin_id);
|
||||
if (!fresh) {
|
||||
warn(`Jellyfin returned no item for ${row.jellyfin_id} after refresh`);
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
|
||||
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
|
||||
const [radarrLibrary, sonarrLibrary] = await Promise.all([
|
||||
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
|
||||
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
await upsertJellyfinItem(
|
||||
db,
|
||||
fresh,
|
||||
{
|
||||
audioLanguages: parseLanguageList(cfg.audio_languages, []),
|
||||
radarr: radarrEnabled ? radarrCfg : null,
|
||||
sonarr: sonarrEnabled ? sonarrCfg : null,
|
||||
radarrLibrary,
|
||||
sonarrLibrary,
|
||||
},
|
||||
{ executed: true },
|
||||
);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
@@ -531,15 +488,14 @@ async function runJob(job: Job): Promise<void> {
|
||||
log(`Job ${job.id} completed successfully`);
|
||||
emitJobUpdate(job.id, "done", fullOutput);
|
||||
|
||||
// Ask Jellyfin to rescan the file and pull the fresh metadata so our DB
|
||||
// reflects what actually ended up on disk. If the refreshed streams still
|
||||
// don't satisfy is_noop (e.g. a codec didn't transcode as planned), the
|
||||
// plan flips back to 'pending' in the same upsert and the UI shows it.
|
||||
try {
|
||||
await refreshItemFromJellyfin(job.item_id);
|
||||
} catch (refreshErr) {
|
||||
warn(`Post-job refresh for item ${job.item_id} failed: ${String(refreshErr)}`);
|
||||
}
|
||||
// Fire-and-forget: tell Jellyfin to rescan the file. The MQTT subscriber
|
||||
// will pick up Jellyfin's resulting Library event and re-analyze the
|
||||
// item — flipping the plan back to 'pending' if the on-disk streams
|
||||
// don't actually match the plan. We don't await that; the job queue
|
||||
// moves on.
|
||||
handOffToJellyfin(job.item_id).catch((err) =>
|
||||
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`Job ${job.id} failed:`, err);
|
||||
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getUsers, 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";
|
||||
import { testConnection as testSonarr } from "../services/sonarr";
|
||||
@@ -103,6 +104,63 @@ app.patch("/schedule", async (c) => {
|
||||
return c.json(getScheduleConfig());
|
||||
});
|
||||
|
||||
// ─── MQTT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/mqtt", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
|
||||
const url = (body.url ?? "").trim();
|
||||
const topic = (body.topic ?? "jellyfin/events").trim();
|
||||
const username = (body.username ?? "").trim();
|
||||
const password = body.password ?? "";
|
||||
|
||||
setConfig("mqtt_url", url);
|
||||
setConfig("mqtt_topic", topic || "jellyfin/events");
|
||||
setConfig("mqtt_username", username);
|
||||
// Only overwrite password when a non-empty value is sent, so the UI can
|
||||
// leave the field blank to indicate "keep the existing one".
|
||||
if (password) setConfig("mqtt_password", password);
|
||||
|
||||
// Reconnect with the new config. Best-effort; failures surface in status.
|
||||
startMqttClient().catch(() => {});
|
||||
|
||||
return c.json({ ok: true, saved: true });
|
||||
});
|
||||
|
||||
app.get("/mqtt/status", (c) => {
|
||||
return c.json(getMqttStatus());
|
||||
});
|
||||
|
||||
app.post("/mqtt/test", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
|
||||
const url = (body.url ?? "").trim();
|
||||
if (!url) return c.json({ ok: false, error: "Broker URL required" }, 400);
|
||||
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);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns whether Jellyfin has the Webhook plugin installed. The Settings
|
||||
* panel uses this to decide between "setup steps" vs "install this plugin".
|
||||
*/
|
||||
app.get("/jellyfin/webhook-plugin", async (c) => {
|
||||
const url = getConfig("jellyfin_url");
|
||||
const apiKey = getConfig("jellyfin_api_key");
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: "Jellyfin not configured" }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/Plugins`, { headers: { "X-Emby-Token": apiKey } });
|
||||
if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` }, 502);
|
||||
const plugins = (await res.json()) as { Name?: string; Id?: string; Version?: string }[];
|
||||
const hit = plugins.find((p) => typeof p.Name === "string" && p.Name.toLowerCase().includes("webhook"));
|
||||
return c.json({ ok: true, installed: !!hit, plugin: hit ?? null });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/clear-scan", (c) => {
|
||||
const db = getDb();
|
||||
// Delete children first to avoid slow cascade deletes
|
||||
|
||||
Reference in New Issue
Block a user