remove jellyfin, mqtt, webhook services, fix tests, add schema migrations

- delete server/services/jellyfin.ts, webhook.ts, mqtt.ts and their tests
- strip jellyfin/mqtt imports and startup calls from index.tsx and settings.ts
- remove /jellyfin, /mqtt, /mqtt/status, /mqtt/test, /jellyfin/webhook-plugin endpoints from settings router
- clean ENV_MAP and isEnvConfigured of jellyfin/mqtt keys
- add db/index.ts migrations for series_key, duration_seconds, scan_status, scan_error, last_scanned_at (new columns absent on older dev DBs)
- move idx_media_items_series_key out of SCHEMA into migrate() so it runs after the column is added
- fix all test fixtures: drop jellyfin_id/series_jellyfin_id column refs, update MediaItem/MediaStream object literals to match current types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:33:29 +02:00
parent 6b01de5f30
commit 686434f5c3
17 changed files with 37 additions and 951 deletions
+2 -2
View File
@@ -15,9 +15,9 @@ function makeDb(): Database {
function seedQueuedItem(db: Database, id: number, autoClass: "auto" | "auto_heuristic" | "manual") {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'approved', 0, ?, 1, 'direct_play', 'copy')",
@@ -15,9 +15,9 @@ function makeDb(): Database {
function seedSortedPlan(db: Database, id: number, autoClass: "auto_heuristic" | "manual") {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO media_streams (item_id, stream_index, type, codec, language) VALUES (?, 0, 'Audio', 'eac3', 'eng')",
+10 -10
View File
@@ -17,7 +17,7 @@ interface SeedOpts {
type: "Movie" | "Episode";
name?: string;
seriesName?: string | null;
seriesJellyfinId?: string | null;
seriesKey?: string | null;
seasonNumber?: number | null;
episodeNumber?: number | null;
autoClass?: "auto" | "auto_heuristic" | "manual" | null;
@@ -30,7 +30,7 @@ function seed(db: Database, opts: SeedOpts) {
type,
name = `Item ${id}`,
seriesName = null,
seriesJellyfinId = null,
seriesKey = null,
seasonNumber = null,
episodeNumber = null,
autoClass = "manual",
@@ -38,9 +38,9 @@ function seed(db: Database, opts: SeedOpts) {
} = opts;
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, series_name, series_jellyfin_id, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO media_items (id, type, name, series_name, series_key, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(id, `jf-${id}`, type, name, seriesName, seriesJellyfinId, seasonNumber, episodeNumber, `/x/${id}.mkv`);
.run(id, type, name, seriesName, seriesKey, seasonNumber, episodeNumber, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, ?, 'direct_play', 'copy', NULL)",
@@ -56,7 +56,7 @@ describe("buildReviewGroups", () => {
id: i,
type: "Episode",
seriesName: "Breaking Bad",
seriesJellyfinId: "bb",
seriesKey: "bb",
seasonNumber: 1,
episodeNumber: i,
});
@@ -81,7 +81,7 @@ describe("buildReviewGroups", () => {
id: ep,
type: "Episode",
seriesName: "Lost",
seriesJellyfinId: "lost",
seriesKey: "lost",
seasonNumber: 1,
episodeNumber: ep,
});
@@ -91,12 +91,12 @@ describe("buildReviewGroups", () => {
id: 10 + ep,
type: "Episode",
seriesName: "Lost",
seriesJellyfinId: "lost",
seriesKey: "lost",
seasonNumber: 2,
episodeNumber: ep,
});
}
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesJellyfinId: "lost", seasonNumber: null });
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesKey: "lost", seasonNumber: null });
const { groups } = buildReviewGroups(db, { bucket: "review" });
expect(groups).toHaveLength(1);
@@ -125,7 +125,7 @@ describe("buildReviewGroups", () => {
id: 1,
type: "Episode",
seriesName: "Show",
seriesJellyfinId: "s",
seriesKey: "s",
seasonNumber: 1,
episodeNumber: 1,
autoClass: "auto_heuristic",
@@ -134,7 +134,7 @@ describe("buildReviewGroups", () => {
id: 2,
type: "Episode",
seriesName: "Show",
seriesJellyfinId: "s",
seriesKey: "s",
seasonNumber: 1,
episodeNumber: 2,
autoClass: "manual",
@@ -21,7 +21,7 @@ interface AudioSeed {
interface SeedOpts {
id: number;
origLang: string | null;
origLangSource: "radarr" | "sonarr" | "manual" | "jellyfin" | null;
origLangSource: "probe" | "radarr" | "sonarr" | "manual" | null;
needsReview?: number;
audio: AudioSeed[];
}
@@ -29,11 +29,10 @@ interface SeedOpts {
function seedItem(db: Database, opts: SeedOpts): void {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, ?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
"INSERT INTO media_items (id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
)
.run(
opts.id,
`jf-${opts.id}`,
`Item ${opts.id}`,
`/x/${opts.id}.mkv`,
opts.origLang,
@@ -16,9 +16,9 @@ function seedPlan(db: Database, id: number, opts: { sorted?: 0 | 1; status?: str
const { sorted = 1, status = "pending", isNoop = 0 } = opts;
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, ?, ?, 'auto_heuristic', ?, 'direct_play', 'copy')",
+2 -2
View File
@@ -10,8 +10,8 @@ function makeDb(): Database {
if (trimmed) db.run(trimmed);
}
db
.prepare("INSERT INTO media_items (id, jellyfin_id, type, name, file_path) VALUES (?, ?, 'Movie', 'T', '/x.mkv')")
.run(1, "jf-1");
.prepare("INSERT INTO media_items (id, type, name, file_path) VALUES (?, 'Movie', 'T', '/x.mkv')")
.run(1);
return db;
}
+3 -112
View File
@@ -1,7 +1,5 @@
import { Hono } from "hono";
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";
@@ -11,7 +9,7 @@ const app = new Hono();
// Config keys that hold credentials. `GET /` returns these as "***" when set,
// "" when unset. Real values only reach the client via the explicit
// GET /reveal?key=<key> endpoint (eye-icon toggle in the settings UI).
const SECRET_KEYS = new Set(["jellyfin_api_key", "radarr_api_key", "sonarr_api_key", "mqtt_password"]);
const SECRET_KEYS = new Set(["radarr_api_key", "sonarr_api_key"]);
app.get("/", (c) => {
const config = getAllConfig();
@@ -35,39 +33,6 @@ function resolveSecret(incoming: string | undefined, storedKey: string): string
return incoming ?? "";
}
app.post("/jellyfin", async (c) => {
const body = await c.req.json<{ url: string; api_key: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = resolveSecret(body.api_key, "jellyfin_api_key");
if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400);
// Save first so the user's input is never silently dropped on a test
// failure (matches the Radarr/Sonarr pattern). The frontend reads the
// { ok, saved, testError } shape to decide what message to show.
setConfig("jellyfin_url", url);
setConfig("jellyfin_api_key", apiKey);
const result = await testJellyfin({ url, apiKey });
// Only mark setup complete when the connection actually works. Setting
// setup_complete=1 on a failing test would let the user click past the
// wizard into an app that then dies on the first Jellyfin call.
if (result.ok) {
setConfig("setup_complete", "1");
// Best-effort admin discovery only when the connection works; ignore failures.
try {
const users = await getUsers({ url, apiKey });
const admin = users.find((u) => u.Name === "admin") ?? users[0];
if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
} catch {
/* ignore */
}
}
return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error });
});
// Persist values BEFORE testing the connection. The previous behaviour
// silently dropped what the user typed when the test failed (e.g. Sonarr
// not yet reachable), making the field appear to "forget" the input on
@@ -169,80 +134,6 @@ app.patch("/schedule", async (c) => {
return c.json(getScheduleConfig());
});
// ─── MQTT ────────────────────────────────────────────────────────────────────
app.post("/mqtt", async (c) => {
const body = await c.req.json<{
enabled?: boolean;
url?: string;
topic?: string;
username?: string;
password?: string;
}>();
const enabled = body.enabled === true;
const url = (body.url ?? "").trim();
const topic = (body.topic ?? "jellyfin/events").trim();
const username = (body.username ?? "").trim();
const password = body.password ?? "";
setConfig("mqtt_enabled", enabled ? "1" : "0");
setConfig("mqtt_url", url);
setConfig("mqtt_topic", topic || "jellyfin/events");
setConfig("mqtt_username", username);
// Only overwrite password when a real value is sent. The UI leaves the
// field blank or sends "***" (masked placeholder) when the user didn't
// touch it — both mean "keep the existing one".
if (password && 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") || "";
// The user triggers real activity in Jellyfin (start playback / add an
// item) while the test runs — a blind metadata refresh from here often
// doesn't fire any webhook (the plugin only emits Item Added on actual
// additions, which a no-op refresh isn't).
const result = await testMqttConnection(
{ url, topic, username: (body.username ?? "").trim(), password },
async () => null,
30_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
@@ -260,8 +151,8 @@ app.post("/clear-scan", (c) => {
/**
* Full factory reset. Truncates every table including config, re-seeds the
* defaults so the setup wizard reappears, and returns. Env-backed config
* keys (JELLYFIN_URL, etc.) continue to resolve via getConfig's env fallback
* — they don't live in the DB to begin with.
* keys continue to resolve via getConfig's env fallback — they don't live
* in the DB to begin with.
*/
app.post("/reset", (c) => {
const db = getDb();
+9 -10
View File
@@ -12,9 +12,6 @@ const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
// ─── Env-var → config key mapping ─────────────────────────────────────────────
const ENV_MAP: Record<string, string> = {
jellyfin_url: "JELLYFIN_URL",
jellyfin_api_key: "JELLYFIN_API_KEY",
jellyfin_user_id: "JELLYFIN_USER_ID",
radarr_url: "RADARR_URL",
radarr_api_key: "RADARR_API_KEY",
radarr_enabled: "RADARR_ENABLED",
@@ -22,11 +19,6 @@ const ENV_MAP: Record<string, string> = {
sonarr_api_key: "SONARR_API_KEY",
sonarr_enabled: "SONARR_ENABLED",
audio_languages: "AUDIO_LANGUAGES",
mqtt_enabled: "MQTT_ENABLED",
mqtt_url: "MQTT_URL",
mqtt_topic: "MQTT_TOPIC",
mqtt_username: "MQTT_USERNAME",
mqtt_password: "MQTT_PASSWORD",
};
/** Read a config key from environment variables (returns null if not set). */
@@ -41,9 +33,9 @@ function envValue(key: string): string | null {
return val;
}
/** True when minimum required Jellyfin env vars are present — skips the setup wizard. */
/** True when env vars are configured enough to skip the setup wizard. */
function isEnvConfigured(): boolean {
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
return !!(process.env.MOVIES_ROOT || process.env.TV_ROOT);
}
// ─── Database ──────────────────────────────────────────────────────────────────
@@ -92,6 +84,13 @@ function migrate(db: Database): void {
// Indexes for new columns — must run after the columns exist on existing DBs
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)");
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)");
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
alter("ALTER TABLE media_items ADD COLUMN series_key TEXT");
alter("ALTER TABLE media_items ADD COLUMN duration_seconds REAL");
alter("ALTER TABLE media_items ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'pending'");
alter("ALTER TABLE media_items ADD COLUMN scan_error TEXT");
alter("ALTER TABLE media_items ADD COLUMN last_scanned_at TEXT");
alter("CREATE INDEX IF NOT EXISTS idx_media_items_series_key ON media_items(series_key)");
}
/**
-1
View File
@@ -96,7 +96,6 @@ CREATE TABLE IF NOT EXISTS jobs (
CREATE INDEX IF NOT EXISTS idx_review_plans_status ON review_plans(status);
CREATE INDEX IF NOT EXISTS idx_review_plans_is_noop ON review_plans(is_noop);
CREATE INDEX IF NOT EXISTS idx_stream_decisions_plan_id ON stream_decisions(plan_id);
CREATE INDEX IF NOT EXISTS idx_media_items_series_key ON media_items(series_key);
CREATE INDEX IF NOT EXISTS idx_media_items_series_name ON media_items(series_name);
CREATE INDEX IF NOT EXISTS idx_media_items_type ON media_items(type);
CREATE INDEX IF NOT EXISTS idx_media_streams_item_id ON media_streams(item_id);
+1 -4
View File
@@ -8,8 +8,7 @@ import reviewRoutes from "./api/review";
import scanRoutes from "./api/scan";
import settingsRoutes from "./api/settings";
import { getDb } from "./db/index";
import { log, error as logError } from "./lib/log";
import { startMqttClient } from "./services/mqtt";
import { log } from "./lib/log";
const app = new Hono();
@@ -67,8 +66,6 @@ log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
getDb();
startMqttClient().catch((err) => logError("MQTT bootstrap failed:", err));
export default {
port,
fetch: app.fetch,
+1 -2
View File
@@ -12,7 +12,6 @@ function stream(o: StreamOverride): MediaStream {
codec: null,
profile: null,
language: null,
language_display: null,
title: null,
is_default: 0,
is_forced: 0,
@@ -514,7 +513,7 @@ describe("analyzeItem — auto_class classification", () => {
test("orig_lang_source=jellyfin is not authoritative → manual", () => {
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
const result = analyzeItem(
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "jellyfin", needs_review: 0 },
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "probe", needs_review: 0 },
streams,
{ audioLanguages: [] },
);
+3 -9
View File
@@ -8,7 +8,6 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
codec: null,
profile: null,
language: null,
language_display: null,
title: null,
is_default: 0,
is_forced: 0,
@@ -36,28 +35,23 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
const ITEM: MediaItem = {
id: 1,
jellyfin_id: "x",
type: "Movie",
name: "Test",
original_title: null,
series_name: null,
series_jellyfin_id: null,
series_key: null,
season_number: null,
episode_number: null,
year: null,
file_path: "/movies/Test.mkv",
file_size: null,
container: "mkv",
runtime_ticks: null,
date_last_refreshed: null,
duration_seconds: null,
original_language: "eng",
orig_lang_source: "jellyfin",
orig_lang_source: "probe",
needs_review: 0,
imdb_id: null,
tmdb_id: null,
tvdb_id: null,
jellyfin_raw: null,
external_raw: null,
scan_status: "scanned",
scan_error: null,
last_scanned_at: null,
@@ -1,46 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { JellyfinItem, JellyfinMediaStream } from "../../types";
import { extractOriginalLanguage } from "../jellyfin";
function audio(o: Partial<JellyfinMediaStream>): JellyfinMediaStream {
return { Type: "Audio", Index: 0, ...o };
}
function item(streams: JellyfinMediaStream[]): JellyfinItem {
return { Id: "x", Type: "Movie", Name: "Test", MediaStreams: streams };
}
describe("extractOriginalLanguage — Jellyfin heuristic", () => {
test("returns null when there are no audio streams", () => {
expect(extractOriginalLanguage(item([{ Type: "Video", Index: 0 }]))).toBe(null);
});
test("uses the only audio track when there is just one", () => {
expect(extractOriginalLanguage(item([audio({ Language: "eng" })]))).toBe("eng");
});
test("prefers the IsDefault audio track over position", () => {
// 8 Mile regression: Turkish dub first, English default further down.
// Old heuristic took the first track and labelled the movie Turkish.
const streams = [audio({ Index: 0, Language: "tur" }), audio({ Index: 1, Language: "eng", IsDefault: true })];
expect(extractOriginalLanguage(item(streams))).toBe("eng");
});
test("skips a dub even when it is the default", () => {
const streams = [
audio({ Index: 0, Language: "tur", IsDefault: true, Title: "Turkish Dub" }),
audio({ Index: 1, Language: "eng" }),
];
expect(extractOriginalLanguage(item(streams))).toBe("eng");
});
test("falls back to first audio track when every track looks like a dub", () => {
const streams = [
audio({ Index: 0, Language: "tur", Title: "Turkish Dub" }),
audio({ Index: 1, Language: "deu", Title: "German Dub" }),
];
// No good candidate — returns the first audio so there's *some* guess,
// but scan.ts is responsible for marking this needs_review.
expect(extractOriginalLanguage(item(streams))).toBe("tur");
});
});
-170
View File
@@ -1,170 +0,0 @@
import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { SCHEMA } from "../../db/schema";
import type { JellyfinItem } from "../../types";
import type { JellyfinConfig } from "../jellyfin";
import type { RescanConfig } from "../rescan";
import { _resetDedupe, processWebhookEvent } from "../webhook";
function makeDb(): Database {
const db = new Database(":memory:");
for (const stmt of SCHEMA.split(";")) {
const trimmed = stmt.trim();
if (trimmed) db.run(trimmed);
}
return db;
}
const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
const RESCAN_CFG: RescanConfig = {};
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
return {
Id: "jf-1",
Type: "Movie",
Name: "Test Movie",
Path: "/movies/Test.mkv",
Container: "mkv",
MediaStreams: [
{ Type: "Video", Index: 0, Codec: "h264" },
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
],
...over,
};
}
describe("processWebhookEvent — acceptance", () => {
beforeEach(() => _resetDedupe());
afterEach(() => _resetDedupe());
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
const db = makeDb();
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
const res = await processWebhookEvent(
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("NotificationType");
}
});
test("rejects non-Movie/Episode types", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Trailer" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("ItemType");
});
test("rejects missing ItemId", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ NotificationType: "ItemAdded", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("ItemId");
});
test("dedupes bursts within 5s and accepts again after", async () => {
const db = makeDb();
let fakeNow = 1_000_000;
const getItemFn = async () => fakeItem();
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
const first = await processWebhookEvent(payload, {
db,
jellyfin: JF,
rescanCfg: RESCAN_CFG,
getItemFn,
now: () => fakeNow,
});
expect(first.accepted).toBe(true);
fakeNow += 1000;
const second = await processWebhookEvent(payload, {
db,
jellyfin: JF,
rescanCfg: RESCAN_CFG,
getItemFn,
now: () => fakeNow,
});
expect(second.accepted).toBe(false);
expect(second.reason).toBe("deduped");
fakeNow += 5001;
const third = await processWebhookEvent(payload, {
db,
jellyfin: JF,
rescanCfg: RESCAN_CFG,
getItemFn,
now: () => fakeNow,
});
expect(third.accepted).toBe(true);
});
test("drops when Jellyfin returns no item", async () => {
const db = makeDb();
const res = await processWebhookEvent(
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
);
expect(res.accepted).toBe(false);
expect(res.reason).toContain("no item");
});
});
describe("processWebhookEvent — done-status override", () => {
beforeEach(() => _resetDedupe());
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
return processWebhookEvent(
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
);
}
function planStatusFor(db: Database, jellyfinId: string): string {
return (
db
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
.get(jellyfinId) as { status: string }
).status;
}
test("a webhook reopens a done plan back to pending (will be re-processed)", async () => {
const db = makeDb();
const fresh = fakeItem();
await runWebhook(db, fresh);
db
.prepare(
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
)
.run(fresh.Id);
_resetDedupe();
await runWebhook(db, fresh);
expect(planStatusFor(db, fresh.Id)).toBe("pending");
});
test("a scan (non-webhook) leaves a done plan as done", async () => {
const db = makeDb();
const fresh = fakeItem();
await runWebhook(db, fresh);
db
.prepare(
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
)
.run(fresh.Id);
// Simulate a rescan (source='scan') via direct upsertJellyfinItem
const { upsertJellyfinItem } = await import("../rescan");
await upsertJellyfinItem(db, fresh, {}, { source: "scan" });
expect(planStatusFor(db, fresh.Id)).toBe("done");
});
});
-250
View File
@@ -1,250 +0,0 @@
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
import { normalizeLanguage } from "./language-utils";
export interface JellyfinConfig {
url: string;
apiKey: string;
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
userId?: string;
}
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
function itemsBaseUrl(cfg: JellyfinConfig): string {
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
}
const PAGE_SIZE = 200;
function headers(apiKey: string): Record<string, string> {
return {
"X-Emby-Token": apiKey,
"Content-Type": "application/json",
};
}
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`${cfg.url}/Users`, {
headers: headers(cfg.apiKey),
});
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
return res.json() as Promise<JellyfinUser[]>;
}
const ITEM_FIELDS = [
"MediaStreams",
"Path",
"ProviderIds",
"SeriesProviderIds",
"OriginalTitle",
"ProductionYear",
"Size",
"Container",
"RunTimeTicks",
"DateLastRefreshed",
].join(",");
export async function* getAllItems(
cfg: JellyfinConfig,
onProgress?: (count: number, total: number) => void,
): AsyncGenerator<JellyfinItem> {
let startIndex = 0;
let total = 0;
do {
const url = new URL(itemsBaseUrl(cfg));
url.searchParams.set("Recursive", "true");
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
url.searchParams.set("Fields", ITEM_FIELDS);
url.searchParams.set("Limit", String(PAGE_SIZE));
url.searchParams.set("StartIndex", String(startIndex));
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
total = body.TotalRecordCount;
for (const item of body.Items) {
yield item;
}
startIndex += body.Items.length;
onProgress?.(startIndex, total);
} while (startIndex < total);
}
/**
* Dev mode: yields 50 random movies + all episodes from 10 random series.
* Used instead of getAllItems() when NODE_ENV=development.
*/
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
// 50 random movies
const movieUrl = new URL(itemsBaseUrl(cfg));
movieUrl.searchParams.set("Recursive", "true");
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
movieUrl.searchParams.set("SortBy", "Random");
movieUrl.searchParams.set("Limit", "50");
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
if (!movieRes.ok)
throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
for (const item of movieBody.Items) yield item;
// 10 random series → yield all their episodes
const seriesUrl = new URL(itemsBaseUrl(cfg));
seriesUrl.searchParams.set("Recursive", "true");
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
seriesUrl.searchParams.set("SortBy", "Random");
seriesUrl.searchParams.set("Limit", "10");
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
for (const series of seriesBody.Items) {
const epUrl = new URL(itemsBaseUrl(cfg));
epUrl.searchParams.set("ParentId", series.Id);
epUrl.searchParams.set("Recursive", "true");
epUrl.searchParams.set("IncludeItemTypes", "Episode");
epUrl.searchParams.set("Fields", ITEM_FIELDS);
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
if (epRes.ok) {
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
for (const ep of epBody.Items) yield ep;
}
}
}
/** Fetch all episodes for a series by its Jellyfin series ID. */
export async function getSeriesEpisodes(cfg: JellyfinConfig, seriesJellyfinId: string): Promise<JellyfinItem[]> {
const url = new URL(itemsBaseUrl(cfg));
url.searchParams.set("ParentId", seriesJellyfinId);
url.searchParams.set("Recursive", "true");
url.searchParams.set("IncludeItemTypes", "Episode");
url.searchParams.set("Fields", ITEM_FIELDS);
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) return [];
const body = (await res.json()) as { Items: JellyfinItem[] };
return body.Items;
}
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
const url = new URL(base);
url.searchParams.set("Fields", ITEM_FIELDS);
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) return null;
return res.json() as Promise<JellyfinItem>;
}
/**
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
* Polls DateLastRefreshed until it changes (or timeout is reached).
*/
/**
* Trigger a Jellyfin metadata refresh and poll until the item's
* `DateLastRefreshed` advances. Returns true when the probe actually ran;
* false on timeout (caller decides whether to trust the item's current
* metadata or treat it as unverified — verification paths should NOT
* proceed on false, since a stale snapshot would give a bogus verdict).
*/
export async function refreshItem(
cfg: JellyfinConfig,
jellyfinId: string,
timeoutMs = 15000,
): Promise<{ refreshed: boolean }> {
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
// 1. Snapshot current DateLastRefreshed
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
const beforeDate = before.DateLastRefreshed;
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
const refreshUrl = new URL(`${itemUrl}/Refresh`);
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
refreshUrl.searchParams.set("ImageRefreshMode", "None");
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
refreshUrl.searchParams.set("ReplaceAllImages", "false");
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
// 3. Poll until DateLastRefreshed changes
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, 1000));
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!checkRes.ok) continue;
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) {
return { refreshed: true };
}
}
return { refreshed: false };
}
/** Case-insensitive hints that a track is a dub / commentary, not the original. */
const DUB_TITLE_HINTS = /(dub|dubb|synchro|commentary|director)/i;
/**
* Jellyfin has no real original_language field, so we guess from audio streams.
* This is the notorious "8 Mile got labelled Turkish" heuristic — guard it:
* 1. Prefer IsDefault audio when available (Jellyfin sets this from the file's
* default disposition flag; uploaders usually set it to the original).
* 2. Skip tracks whose title screams "dub" / "commentary".
* 3. Fall back to the first non-dub audio track, then first audio track.
* The caller must still treat any jellyfin-sourced value as unverified — this
* just makes the guess less wrong. The trustworthy answer comes from Radarr/Sonarr.
*/
export function extractOriginalLanguage(item: JellyfinItem): string | null {
if (!item.MediaStreams) return null;
const audio = item.MediaStreams.filter((s) => s.Type === "Audio");
if (audio.length === 0) return null;
const notDub = (s: JellyfinMediaStream) => !s.Title || !DUB_TITLE_HINTS.test(s.Title);
const pick = audio.find((s) => s.IsDefault && notDub(s)) ?? audio.find(notDub) ?? audio[0];
return pick.Language ? normalizeLanguage(pick.Language) : null;
}
/**
* Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id).
*
* NOTE: stores the raw `Language` value from Jellyfin (e.g. "en", "eng", "ger",
* null). We intentionally do NOT normalize here because `is_noop` compares
* raw → normalized to decide whether the pipeline should rewrite the tag to
* canonical iso3. Callers that compare languages must use normalizeLanguage().
*/
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
return {
stream_index: s.Index,
type: s.Type as MediaStream["type"],
codec: s.Codec ?? null,
profile: s.Profile ?? null,
language: s.Language ?? null,
language_display: s.DisplayLanguage ?? null,
title: s.Title ?? null,
is_default: s.IsDefault ? 1 : 0,
is_forced: s.IsForced ? 1 : 0,
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
channels: s.Channels ?? null,
channel_layout: s.ChannelLayout ?? null,
bit_rate: s.BitRate ?? null,
sample_rate: s.SampleRate ?? null,
bit_depth: s.BitDepth ?? null,
};
}
export { normalizeLanguage } from "./language-utils";
-206
View File
@@ -1,206 +0,0 @@
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 {
if (getConfig("mqtt_enabled") !== "1") return 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");
}
export interface MqttTestResult {
brokerConnected: boolean;
jellyfinTriggered: boolean;
receivedMessage: boolean;
itemName?: string;
expectedItemId?: string;
samplePayload?: string;
error?: string;
}
/**
* 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<MqttTestResult> {
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;
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({
brokerConnected,
jellyfinTriggered,
expectedItemId: expectedItemId ?? undefined,
itemName,
...result,
});
};
c.on("connect", () => {
brokerConnected = true;
c.subscribe(cfg.topic, { qos: 0 }, async (err) => {
if (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({ receivedMessage: false }), timeoutMs);
});
c.on("message", (_topic, payload) => {
// Any message on the configured topic is enough — a rescan of an
// unchanged item won't fire Item Added, so the "itemId matches"
// filter would cause false failures. The user triggers real
// activity in Jellyfin if the auto-rescan doesn't wake anything.
done({ receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 400) });
});
c.on("error", (err) => {
done({ receivedMessage: false, error: String(err) });
});
});
}
-120
View File
@@ -1,120 +0,0 @@
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 { type RescanConfig, type RescanResult, upsertJellyfinItem } from "./rescan";
/**
* Events we care about. Jellyfin's Webhook plugin (jellyfin-plugin-webhook)
* only exposes ItemAdded as a library-side notification — there is no
* ItemUpdated or Library.ItemUpdated. File-rewrites on existing items
* produce zero MQTT traffic, so we can't observe them here; the UI's
* post-job verification runs off our own ffprobe instead.
*
* Payload fields are PascalCase (NotificationType, ItemId, ItemType) — the
* earlier camelCase in this handler matched nothing the plugin ever sends.
*/
const ACCEPTED_EVENTS = new Set(["ItemAdded"]);
const ACCEPTED_TYPES = new Set(["Movie", "Episode"]);
/** 5-second dedupe window: Jellyfin can fire the same ItemAdded twice when multiple libraries share a path. */
const DEDUPE_WINDOW_MS = 5000;
const dedupe = new Map<string, number>();
export interface WebhookPayload {
NotificationType?: 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.NotificationType || !ACCEPTED_EVENTS.has(payload.NotificationType)) {
return { accepted: false, reason: `NotificationType '${payload.NotificationType}' 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: ingested ${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 rescanCfg: RescanConfig = {};
return processWebhookEvent(payload, { db: getDb(), jellyfin, rescanCfg });
}
/** Exposed for tests. */
export function _resetDedupe(): void {
dedupe.clear();
}