import { Hono } from "hono"; import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index"; 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(); // 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(["radarr_api_key", "sonarr_api_key"]); app.get("/", (c) => { const config = getAllConfig(); for (const key of SECRET_KEYS) { if (config[key]) config[key] = "***"; } const envLocked = Array.from(getEnvLockedKeys()); return c.json({ config, envLocked }); }); app.get("/reveal", (c) => { const key = c.req.query("key") ?? ""; if (!SECRET_KEYS.has(key)) return c.json({ error: "not a secret key" }, 400); return c.json({ value: getConfig(key) ?? "" }); }); // The UI sends "***" as a sentinel meaning "user didn't touch this field, // keep the stored value". Save endpoints call this before writing a secret. function resolveSecret(incoming: string | undefined, storedKey: string): string { if (incoming === "***") return getConfig(storedKey) ?? ""; return incoming ?? ""; } // 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 = resolveSecret(body.api_key, "radarr_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 = resolveSecret(body.api_key, "sonarr_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 }); }); // Toggle the auto-processing flag. When flipped on, trigger a one-shot // sort-inbox pass so existing Inbox items drain immediately without waiting // for the next scan. app.post("/auto-processing", async (c) => { const body = await c.req.json<{ enabled?: unknown }>().catch(() => ({ enabled: null })); if (typeof body.enabled !== "boolean") { return c.json({ ok: false, error: "enabled must be a boolean" }, 400); } setConfig("auto_processing", body.enabled ? "1" : "0"); if (body.enabled) { const { processInbox, getAudioLanguages } = await import("./review"); const { emitInboxSorted, emitInboxSortStart, emitInboxSortProgress } = await import("./execute"); processInbox(getDb(), getAudioLanguages(), undefined, { onStart: emitInboxSortStart, onProgress: emitInboxSortProgress, }) .then((result) => emitInboxSorted(result)) .catch(() => emitInboxSorted({ moved_to_queue: 0, moved_to_review: 0 })); } return c.json({ ok: true, enabled: body.enabled }); }); // Toggle the auto-process-queue flag. When flipped on, kick the queue // processor so any already-pending jobs start draining immediately without // waiting for the next approval to trigger it. app.post("/auto-process-queue", async (c) => { const body = await c.req.json<{ enabled?: unknown }>().catch(() => ({ enabled: null })); if (typeof body.enabled !== "boolean") { return c.json({ ok: false, error: "enabled must be a boolean" }, 400); } setConfig("auto_process_queue", body.enabled ? "1" : "0"); if (body.enabled) { const { maybeStartQueueProcessor } = await import("./execute"); const started = maybeStartQueueProcessor(); return c.json({ ok: true, enabled: true, started }); } return c.json({ ok: true, enabled: false }); }); 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()); }); 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 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 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 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;