From 686434f5c31ee03dc19fff8a14fc0ba062781dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 20 Apr 2026 19:33:29 +0200 Subject: [PATCH] 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 --- server/api/__tests__/execute-clear.test.ts | 4 +- .../__tests__/review-approve-ready.test.ts | 4 +- server/api/__tests__/review-groups.test.ts | 20 +- .../api/__tests__/review-sort-inbox.test.ts | 5 +- .../__tests__/review-unsort-reopen.test.ts | 4 +- server/api/__tests__/review.test.ts | 4 +- server/api/settings.ts | 115 +------- server/db/index.ts | 19 +- server/db/schema.ts | 1 - server/index.tsx | 5 +- server/services/__tests__/analyzer.test.ts | 3 +- server/services/__tests__/ffmpeg.test.ts | 12 +- server/services/__tests__/jellyfin.test.ts | 46 ---- server/services/__tests__/webhook.test.ts | 170 ------------ server/services/jellyfin.ts | 250 ------------------ server/services/mqtt.ts | 206 --------------- server/services/webhook.ts | 120 --------- 17 files changed, 37 insertions(+), 951 deletions(-) delete mode 100644 server/services/__tests__/jellyfin.test.ts delete mode 100644 server/services/__tests__/webhook.test.ts delete mode 100644 server/services/jellyfin.ts delete mode 100644 server/services/mqtt.ts delete mode 100644 server/services/webhook.ts diff --git a/server/api/__tests__/execute-clear.test.ts b/server/api/__tests__/execute-clear.test.ts index 13b02b4..e9e4bca 100644 --- a/server/api/__tests__/execute-clear.test.ts +++ b/server/api/__tests__/execute-clear.test.ts @@ -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')", diff --git a/server/api/__tests__/review-approve-ready.test.ts b/server/api/__tests__/review-approve-ready.test.ts index 6757508..848def0 100644 --- a/server/api/__tests__/review-approve-ready.test.ts +++ b/server/api/__tests__/review-approve-ready.test.ts @@ -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')", diff --git a/server/api/__tests__/review-groups.test.ts b/server/api/__tests__/review-groups.test.ts index b262549..4803fd7 100644 --- a/server/api/__tests__/review-groups.test.ts +++ b/server/api/__tests__/review-groups.test.ts @@ -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", diff --git a/server/api/__tests__/review-sort-inbox.test.ts b/server/api/__tests__/review-sort-inbox.test.ts index d7ed49e..1289b37 100644 --- a/server/api/__tests__/review-sort-inbox.test.ts +++ b/server/api/__tests__/review-sort-inbox.test.ts @@ -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, diff --git a/server/api/__tests__/review-unsort-reopen.test.ts b/server/api/__tests__/review-unsort-reopen.test.ts index 6747c5e..e601dfd 100644 --- a/server/api/__tests__/review-unsort-reopen.test.ts +++ b/server/api/__tests__/review-unsort-reopen.test.ts @@ -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')", diff --git a/server/api/__tests__/review.test.ts b/server/api/__tests__/review.test.ts index 1a21ec4..a6e47f9 100644 --- a/server/api/__tests__/review.test.ts +++ b/server/api/__tests__/review.test.ts @@ -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; } diff --git a/server/api/settings.ts b/server/api/settings.ts index e447ee1..d7e0e4b 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -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= 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(); diff --git a/server/db/index.ts b/server/db/index.ts index 721c51f..a7377a6 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -12,9 +12,6 @@ const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db"); // ─── Env-var → config key mapping ───────────────────────────────────────────── const ENV_MAP: Record = { - 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 = { 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)"); } /** diff --git a/server/db/schema.ts b/server/db/schema.ts index 44fcd94..7a464f5 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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); diff --git a/server/index.tsx b/server/index.tsx index 33e4e10..afb7181 100644 --- a/server/index.tsx +++ b/server/index.tsx @@ -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, diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index f3b3944..494fd8d 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -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: [] }, ); diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts index 84bbd5a..08a4c56 100644 --- a/server/services/__tests__/ffmpeg.test.ts +++ b/server/services/__tests__/ffmpeg.test.ts @@ -8,7 +8,6 @@ function stream(o: Partial & Pick & Pick): 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"); - }); -}); diff --git a/server/services/__tests__/webhook.test.ts b/server/services/__tests__/webhook.test.ts deleted file mode 100644 index a65ceaf..0000000 --- a/server/services/__tests__/webhook.test.ts +++ /dev/null @@ -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 { - 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"); - }); -}); diff --git a/server/services/jellyfin.ts b/server/services/jellyfin.ts deleted file mode 100644 index b83a5b3..0000000 --- a/server/services/jellyfin.ts +++ /dev/null @@ -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 { - 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): Promise { - 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; -} - -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 { - 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 { - // 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 { - 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 { - 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; -} - -/** - * 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 { - 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"; diff --git a/server/services/mqtt.ts b/server/services/mqtt.ts deleted file mode 100644 index b6c2678..0000000 --- a/server/services/mqtt.ts +++ /dev/null @@ -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 { - 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 { - if (!client) return; - const c = client; - client = null; - await new Promise((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 { - 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) => { - 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) }); - }); - }); -} diff --git a/server/services/webhook.ts b/server/services/webhook.ts deleted file mode 100644 index a66d660..0000000 --- a/server/services/webhook.ts +++ /dev/null @@ -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(); - - -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 { - 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 { - 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(); -}