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.
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
import { getAllConfig, getDb } from "../db/index";
|
|
import { log, warn } from "../lib/log";
|
|
import { getItem, type JellyfinConfig } from "./jellyfin";
|
|
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "./radarr";
|
|
import { type RescanConfig, type RescanResult, upsertJellyfinItem } from "./rescan";
|
|
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "./sonarr";
|
|
|
|
/**
|
|
* Events we care about. Jellyfin's Webhook plugin emits many event types;
|
|
* Library.ItemAdded and Library.ItemUpdated are the only ones that signal
|
|
* an on-disk file mutation. We ignore user-data changes, playback, etc.
|
|
*/
|
|
const ACCEPTED_EVENTS = new Set(["ItemAdded", "ItemUpdated", "Library.ItemAdded", "Library.ItemUpdated"]);
|
|
const ACCEPTED_TYPES = new Set(["Movie", "Episode"]);
|
|
|
|
/** 5-second dedupe window: Jellyfin fires ItemUpdated multiple times per rescan. */
|
|
const DEDUPE_WINDOW_MS = 5000;
|
|
const dedupe = new Map<string, number>();
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
export interface WebhookPayload {
|
|
event?: string;
|
|
itemId?: string;
|
|
itemType?: string;
|
|
}
|
|
|
|
export interface WebhookHandlerDeps {
|
|
db: Database;
|
|
jellyfin: JellyfinConfig;
|
|
rescanCfg: RescanConfig;
|
|
getItemFn?: typeof getItem;
|
|
now?: () => number;
|
|
}
|
|
|
|
export interface WebhookResult {
|
|
accepted: boolean;
|
|
reason?: string;
|
|
result?: RescanResult;
|
|
}
|
|
|
|
/**
|
|
* Parse an incoming webhook payload and, if it describes a relevant Jellyfin
|
|
* library event for a Movie/Episode, re-analyze the item and let rescan's
|
|
* webhook-override flip stale 'done' plans back to 'pending'.
|
|
*
|
|
* Errors from Jellyfin are logged and swallowed: one bad message must not
|
|
* take down the MQTT subscriber.
|
|
*/
|
|
export async function processWebhookEvent(payload: WebhookPayload, deps: WebhookHandlerDeps): Promise<WebhookResult> {
|
|
const { db, jellyfin, rescanCfg, getItemFn = getItem, now = Date.now } = deps;
|
|
|
|
if (!payload.event || !ACCEPTED_EVENTS.has(payload.event)) {
|
|
return { accepted: false, reason: `event '${payload.event}' not accepted` };
|
|
}
|
|
if (!payload.itemType || !ACCEPTED_TYPES.has(payload.itemType)) {
|
|
return { accepted: false, reason: `itemType '${payload.itemType}' not accepted` };
|
|
}
|
|
if (!payload.itemId) {
|
|
return { accepted: false, reason: "missing itemId" };
|
|
}
|
|
|
|
// Debounce: drop bursts within the window, always evict stale entries.
|
|
const ts = now();
|
|
for (const [id, seen] of dedupe) {
|
|
if (ts - seen > DEDUPE_WINDOW_MS) dedupe.delete(id);
|
|
}
|
|
const last = dedupe.get(payload.itemId);
|
|
if (last != null && ts - last <= DEDUPE_WINDOW_MS) {
|
|
return { accepted: false, reason: "deduped" };
|
|
}
|
|
dedupe.set(payload.itemId, ts);
|
|
|
|
const fresh = await getItemFn(jellyfin, payload.itemId);
|
|
if (!fresh) {
|
|
warn(`Webhook: Jellyfin returned no item for ${payload.itemId}`);
|
|
return { accepted: false, reason: "jellyfin returned no item" };
|
|
}
|
|
|
|
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
|
|
log(`Webhook: reanalyzed ${payload.itemType} ${payload.itemId} is_noop=${result.isNoop}`);
|
|
return { accepted: true, result };
|
|
}
|
|
|
|
/**
|
|
* MQTT-facing adapter: parses the raw payload text, pulls config, calls
|
|
* processWebhookEvent. Exposed so server/services/mqtt.ts can stay purely
|
|
* about transport, and tests can drive the logic without spinning up MQTT.
|
|
*/
|
|
export async function handleWebhookMessage(rawPayload: string): Promise<WebhookResult> {
|
|
let payload: WebhookPayload;
|
|
try {
|
|
payload = JSON.parse(rawPayload);
|
|
} catch (err) {
|
|
warn(`Webhook: malformed JSON payload: ${String(err)}`);
|
|
return { accepted: false, reason: "malformed JSON" };
|
|
}
|
|
|
|
const cfg = getAllConfig();
|
|
const jellyfin: JellyfinConfig = {
|
|
url: cfg.jellyfin_url,
|
|
apiKey: cfg.jellyfin_api_key,
|
|
userId: cfg.jellyfin_user_id,
|
|
};
|
|
if (!jellyfin.url || !jellyfin.apiKey) {
|
|
return { accepted: false, reason: "jellyfin not configured" };
|
|
}
|
|
|
|
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),
|
|
]);
|
|
|
|
const rescanCfg: RescanConfig = {
|
|
audioLanguages: parseLanguageList(cfg.audio_languages, []),
|
|
radarr: radarrEnabled ? radarrCfg : null,
|
|
sonarr: sonarrEnabled ? sonarrCfg : null,
|
|
radarrLibrary,
|
|
sonarrLibrary,
|
|
};
|
|
|
|
return processWebhookEvent(payload, { db: getDb(), jellyfin, rescanCfg });
|
|
}
|
|
|
|
/** Exposed for tests. */
|
|
export function _resetDedupe(): void {
|
|
dedupe.clear();
|
|
}
|