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"; const app = new Hono(); app.get("/", (c) => { const config = getAllConfig(); const envLocked = Array.from(getEnvLockedKeys()); return c.json({ config, envLocked }); }); app.post("/jellyfin", async (c) => { const body = await c.req.json<{ url: string; api_key: string }>(); const url = body.url?.replace(/\/$/, ""); const apiKey = body.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); setConfig("setup_complete", "1"); const result = await testJellyfin({ url, apiKey }); // Best-effort admin discovery only when the connection works; ignore failures. if (result.ok) { 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 // reload. Save first, surface the test result as a warning the UI can show. app.post("/radarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); const url = body.url?.replace(/\/$/, ""); const apiKey = body.api_key; if (!url || !apiKey) { setConfig("radarr_enabled", "0"); return c.json({ ok: false, error: "URL and API key are required" }, 400); } setConfig("radarr_url", url); setConfig("radarr_api_key", apiKey); setConfig("radarr_enabled", "1"); const result = await testRadarr({ url, apiKey }); return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error }); }); app.post("/sonarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); const url = body.url?.replace(/\/$/, ""); const apiKey = body.api_key; if (!url || !apiKey) { setConfig("sonarr_enabled", "0"); return c.json({ ok: false, error: "URL and API key are required" }, 400); } setConfig("sonarr_url", url); setConfig("sonarr_api_key", apiKey); setConfig("sonarr_enabled", "1"); const result = await testSonarr({ url, apiKey }); return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error }); }); app.post("/audio-languages", async (c) => { const body = await c.req.json<{ langs: string[] }>(); setConfig("audio_languages", JSON.stringify(body.langs ?? [])); return c.json({ ok: true }); }); app.get("/schedule", (c) => { return c.json(getScheduleConfig()); }); app.patch("/schedule", async (c) => { const body = await c.req.json>(); try { updateScheduleConfig(body); } catch (e) { return c.json({ error: e instanceof Error ? e.message : String(e) }, 400); } return c.json(getScheduleConfig()); }); // ─── MQTT ──────────────────────────────────────────────────────────────────── app.post("/mqtt", async (c) => { const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>(); const url = (body.url ?? "").trim(); const topic = (body.topic ?? "jellyfin/events").trim(); const username = (body.username ?? "").trim(); const password = body.password ?? ""; setConfig("mqtt_url", url); setConfig("mqtt_topic", topic || "jellyfin/events"); setConfig("mqtt_username", username); // Only overwrite password when a non-empty value is sent, so the UI can // leave the field blank to indicate "keep the existing one". if (password) setConfig("mqtt_password", password); // Reconnect with the new config. Best-effort; failures surface in status. startMqttClient().catch(() => {}); return c.json({ ok: true, saved: true }); }); app.get("/mqtt/status", (c) => { return c.json(getMqttStatus()); }); app.post("/mqtt/test", async (c) => { const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>(); const url = (body.url ?? "").trim(); if (!url) return c.json({ ok: false, error: "Broker URL required" }, 400); const topic = (body.topic ?? "jellyfin/events").trim() || "jellyfin/events"; const password = body.password || getConfig("mqtt_password") || ""; const result = await testMqttConnection({ url, topic, username: (body.username ?? "").trim(), password }, 15_000); return c.json(result); }); /** * Returns whether Jellyfin has the Webhook plugin installed. The Settings * panel uses this to decide between "setup steps" vs "install this plugin". */ app.get("/jellyfin/webhook-plugin", async (c) => { const url = getConfig("jellyfin_url"); const apiKey = getConfig("jellyfin_api_key"); if (!url || !apiKey) return c.json({ ok: false, error: "Jellyfin not configured" }, 400); try { const res = await fetch(`${url}/Plugins`, { headers: { "X-Emby-Token": apiKey } }); if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` }, 502); const plugins = (await res.json()) as { Name?: string; Id?: string; Version?: string }[]; const hit = plugins.find((p) => typeof p.Name === "string" && p.Name.toLowerCase().includes("webhook")); return c.json({ ok: true, installed: !!hit, plugin: hit ?? null }); } catch (err) { return c.json({ ok: false, error: String(err) }, 502); } }); app.post("/clear-scan", (c) => { const db = getDb(); // Delete children first to avoid slow cascade deletes db.transaction(() => { db.prepare("DELETE FROM stream_decisions").run(); db.prepare("DELETE FROM jobs").run(); db.prepare("DELETE FROM subtitle_files").run(); db.prepare("DELETE FROM review_plans").run(); db.prepare("DELETE FROM media_streams").run(); db.prepare("DELETE FROM media_items").run(); db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run(); })(); return c.json({ ok: true }); }); /** * 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. */ app.post("/reset", (c) => { const db = getDb(); db.transaction(() => { // Order matters when ON DELETE CASCADE isn't consistent across versions. db.prepare("DELETE FROM stream_decisions").run(); db.prepare("DELETE FROM jobs").run(); db.prepare("DELETE FROM subtitle_files").run(); db.prepare("DELETE FROM review_plans").run(); db.prepare("DELETE FROM media_streams").run(); db.prepare("DELETE FROM media_items").run(); db.prepare("DELETE FROM config").run(); })(); reseedDefaults(); return c.json({ ok: true }); }); export default app;