remove jellyfin, mqtt, webhook services, fix tests, add schema migrations

- 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>
This commit is contained in:
2026-04-20 19:33:29 +02:00
parent 6b01de5f30
commit 686434f5c3
17 changed files with 37 additions and 951 deletions
+2 -2
View File
@@ -15,9 +15,9 @@ function makeDb(): Database {
function seedQueuedItem(db: Database, id: number, autoClass: "auto" | "auto_heuristic" | "manual") {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'approved', 0, ?, 1, 'direct_play', 'copy')",
@@ -15,9 +15,9 @@ function makeDb(): Database {
function seedSortedPlan(db: Database, id: number, autoClass: "auto_heuristic" | "manual") {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO media_streams (item_id, stream_index, type, codec, language) VALUES (?, 0, 'Audio', 'eac3', 'eng')",
+10 -10
View File
@@ -17,7 +17,7 @@ interface SeedOpts {
type: "Movie" | "Episode";
name?: string;
seriesName?: string | null;
seriesJellyfinId?: string | null;
seriesKey?: string | null;
seasonNumber?: number | null;
episodeNumber?: number | null;
autoClass?: "auto" | "auto_heuristic" | "manual" | null;
@@ -30,7 +30,7 @@ function seed(db: Database, opts: SeedOpts) {
type,
name = `Item ${id}`,
seriesName = null,
seriesJellyfinId = null,
seriesKey = null,
seasonNumber = null,
episodeNumber = null,
autoClass = "manual",
@@ -38,9 +38,9 @@ function seed(db: Database, opts: SeedOpts) {
} = opts;
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, series_name, series_jellyfin_id, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO media_items (id, type, name, series_name, series_key, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(id, `jf-${id}`, type, name, seriesName, seriesJellyfinId, seasonNumber, episodeNumber, `/x/${id}.mkv`);
.run(id, type, name, seriesName, seriesKey, seasonNumber, episodeNumber, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, ?, 'direct_play', 'copy', NULL)",
@@ -56,7 +56,7 @@ describe("buildReviewGroups", () => {
id: i,
type: "Episode",
seriesName: "Breaking Bad",
seriesJellyfinId: "bb",
seriesKey: "bb",
seasonNumber: 1,
episodeNumber: i,
});
@@ -81,7 +81,7 @@ describe("buildReviewGroups", () => {
id: ep,
type: "Episode",
seriesName: "Lost",
seriesJellyfinId: "lost",
seriesKey: "lost",
seasonNumber: 1,
episodeNumber: ep,
});
@@ -91,12 +91,12 @@ describe("buildReviewGroups", () => {
id: 10 + ep,
type: "Episode",
seriesName: "Lost",
seriesJellyfinId: "lost",
seriesKey: "lost",
seasonNumber: 2,
episodeNumber: ep,
});
}
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesJellyfinId: "lost", seasonNumber: null });
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesKey: "lost", seasonNumber: null });
const { groups } = buildReviewGroups(db, { bucket: "review" });
expect(groups).toHaveLength(1);
@@ -125,7 +125,7 @@ describe("buildReviewGroups", () => {
id: 1,
type: "Episode",
seriesName: "Show",
seriesJellyfinId: "s",
seriesKey: "s",
seasonNumber: 1,
episodeNumber: 1,
autoClass: "auto_heuristic",
@@ -134,7 +134,7 @@ describe("buildReviewGroups", () => {
id: 2,
type: "Episode",
seriesName: "Show",
seriesJellyfinId: "s",
seriesKey: "s",
seasonNumber: 1,
episodeNumber: 2,
autoClass: "manual",
@@ -21,7 +21,7 @@ interface AudioSeed {
interface SeedOpts {
id: number;
origLang: string | null;
origLangSource: "radarr" | "sonarr" | "manual" | "jellyfin" | null;
origLangSource: "probe" | "radarr" | "sonarr" | "manual" | null;
needsReview?: number;
audio: AudioSeed[];
}
@@ -29,11 +29,10 @@ interface SeedOpts {
function seedItem(db: Database, opts: SeedOpts): void {
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, ?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
"INSERT INTO media_items (id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
)
.run(
opts.id,
`jf-${opts.id}`,
`Item ${opts.id}`,
`/x/${opts.id}.mkv`,
opts.origLang,
@@ -16,9 +16,9 @@ function seedPlan(db: Database, id: number, opts: { sorted?: 0 | 1; status?: str
const { sorted = 1, status = "pending", isNoop = 0 } = opts;
db
.prepare(
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container) VALUES (?, ?, 'Movie', ?, ?, 'mkv')",
"INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')",
)
.run(id, `jf-${id}`, `Item ${id}`, `/x/${id}.mkv`);
.run(id, `Item ${id}`, `/x/${id}.mkv`);
db
.prepare(
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, ?, ?, 'auto_heuristic', ?, 'direct_play', 'copy')",
+2 -2
View File
@@ -10,8 +10,8 @@ function makeDb(): Database {
if (trimmed) db.run(trimmed);
}
db
.prepare("INSERT INTO media_items (id, jellyfin_id, type, name, file_path) VALUES (?, ?, 'Movie', 'T', '/x.mkv')")
.run(1, "jf-1");
.prepare("INSERT INTO media_items (id, type, name, file_path) VALUES (?, 'Movie', 'T', '/x.mkv')")
.run(1);
return db;
}
+3 -112
View File
@@ -1,7 +1,5 @@
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";
@@ -11,7 +9,7 @@ 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(["jellyfin_api_key", "radarr_api_key", "sonarr_api_key", "mqtt_password"]);
const SECRET_KEYS = new Set(["radarr_api_key", "sonarr_api_key"]);
app.get("/", (c) => {
const config = getAllConfig();
@@ -35,39 +33,6 @@ function resolveSecret(incoming: string | undefined, storedKey: string): string
return incoming ?? "";
}
app.post("/jellyfin", async (c) => {
const body = await c.req.json<{ url: string; api_key: string }>();
const url = body.url?.replace(/\/$/, "");
const apiKey = resolveSecret(body.api_key, "jellyfin_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);
const result = await testJellyfin({ url, apiKey });
// Only mark setup complete when the connection actually works. Setting
// setup_complete=1 on a failing test would let the user click past the
// wizard into an app that then dies on the first Jellyfin call.
if (result.ok) {
setConfig("setup_complete", "1");
// Best-effort admin discovery only when the connection works; ignore failures.
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
@@ -169,80 +134,6 @@ app.patch("/schedule", async (c) => {
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 real value is sent. The UI leaves the
// field blank or sends "***" (masked placeholder) when the user didn't
// touch it — both mean "keep the existing one".
if (password && 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") || "";
// The user triggers real activity in Jellyfin (start playback / add an
// item) while the test runs — a blind metadata refresh from here often
// doesn't fire any webhook (the plugin only emits Item Added on actual
// additions, which a no-op refresh isn't).
const result = await testMqttConnection(
{ url, topic, username: (body.username ?? "").trim(), password },
async () => null,
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
@@ -260,8 +151,8 @@ app.post("/clear-scan", (c) => {
/**
* 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.
* 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();