686434f5c3
- 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>
173 lines
6.3 KiB
TypeScript
173 lines
6.3 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 { 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<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;
|