78d569189f
Build and Push Docker Image / build (push) Successful in 1m18s
All three processInbox callers (manual button, auto-processing toggle, post-scan auto-process) now go through startProcessInbox() which manages the shared abort controller. Previously only the manual button set the abort controller, so Stop Sorting had no effect when processing was triggered from the settings toggle or after scan completion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
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=<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 { startProcessInbox } = await import("./review");
|
|
startProcessInbox();
|
|
}
|
|
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<Partial<ScheduleConfig>>();
|
|
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;
|