All checks were successful
Build and Push Docker Image / build (push) Successful in 1m29s
- MqttSection now renders as a nested block inside the Jellyfin ConnSection instead of its own card; ConnSection grew a children slot - when the enable checkbox is off, broker/topic/credentials inputs and the whole plugin setup panel are hidden; only the toggle + a small save button remain - 'Test Connection' became 'Test end-to-end': connects to the broker, subscribes, picks a random scanned movie/episode, asks jellyfin to refresh it, and waits for a matching webhook message. the UI walks through all three steps (broker reachable → jellyfin rescan triggered → webhook received) with per-step success/failure so a broken plugin config is obvious
234 lines
8.8 KiB
TypeScript
234 lines
8.8 KiB
TypeScript
import { Hono } from "hono";
|
|
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
|
import { getUsers, refreshItem, 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<Partial<ScheduleConfig>>();
|
|
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<{
|
|
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 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 jellyfinUrl = getConfig("jellyfin_url") ?? "";
|
|
const jellyfinApiKey = getConfig("jellyfin_api_key") ?? "";
|
|
const jellyfinUserId = getConfig("jellyfin_user_id") ?? "";
|
|
const jfCfg = { url: jellyfinUrl, apiKey: jellyfinApiKey, userId: jellyfinUserId };
|
|
|
|
const triggerRefresh = async (): Promise<{ itemId: string; itemName: string } | null> => {
|
|
if (!jellyfinUrl || !jellyfinApiKey) return null;
|
|
// Grab any scanned movie/episode as the trigger target. Random ordering
|
|
// so repeated tests don't keep hammering the same item.
|
|
const row = getDb()
|
|
.prepare("SELECT jellyfin_id, name FROM media_items WHERE type IN ('Movie', 'Episode') ORDER BY RANDOM() LIMIT 1")
|
|
.get() as { jellyfin_id: string; name: string } | undefined;
|
|
if (!row) return null;
|
|
// Fire-and-poll: refreshItem blocks until Jellyfin finishes, which we
|
|
// don't want here — the webhook fires during the refresh itself.
|
|
// Kick it off in the background; the mqtt subscriber catches the event.
|
|
refreshItem(jfCfg, row.jellyfin_id, 2_000).catch(() => {});
|
|
return { itemId: row.jellyfin_id, itemName: row.name };
|
|
};
|
|
|
|
const result = await testMqttConnection(
|
|
{ url, topic, username: (body.username ?? "").trim(), password },
|
|
triggerRefresh,
|
|
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
|
|
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;
|