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:
@@ -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')",
|
||||
|
||||
@@ -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')",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
+9
-10
@@ -12,9 +12,6 @@ const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
|
||||
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
||||
|
||||
const ENV_MAP: Record<string, string> = {
|
||||
jellyfin_url: "JELLYFIN_URL",
|
||||
jellyfin_api_key: "JELLYFIN_API_KEY",
|
||||
jellyfin_user_id: "JELLYFIN_USER_ID",
|
||||
radarr_url: "RADARR_URL",
|
||||
radarr_api_key: "RADARR_API_KEY",
|
||||
radarr_enabled: "RADARR_ENABLED",
|
||||
@@ -22,11 +19,6 @@ const ENV_MAP: Record<string, string> = {
|
||||
sonarr_api_key: "SONARR_API_KEY",
|
||||
sonarr_enabled: "SONARR_ENABLED",
|
||||
audio_languages: "AUDIO_LANGUAGES",
|
||||
mqtt_enabled: "MQTT_ENABLED",
|
||||
mqtt_url: "MQTT_URL",
|
||||
mqtt_topic: "MQTT_TOPIC",
|
||||
mqtt_username: "MQTT_USERNAME",
|
||||
mqtt_password: "MQTT_PASSWORD",
|
||||
};
|
||||
|
||||
/** Read a config key from environment variables (returns null if not set). */
|
||||
@@ -41,9 +33,9 @@ function envValue(key: string): string | null {
|
||||
return val;
|
||||
}
|
||||
|
||||
/** True when minimum required Jellyfin env vars are present — skips the setup wizard. */
|
||||
/** True when env vars are configured enough to skip the setup wizard. */
|
||||
function isEnvConfigured(): boolean {
|
||||
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
|
||||
return !!(process.env.MOVIES_ROOT || process.env.TV_ROOT);
|
||||
}
|
||||
|
||||
// ─── Database ──────────────────────────────────────────────────────────────────
|
||||
@@ -92,6 +84,13 @@ function migrate(db: Database): void {
|
||||
// Indexes for new columns — must run after the columns exist on existing DBs
|
||||
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)");
|
||||
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)");
|
||||
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
|
||||
alter("ALTER TABLE media_items ADD COLUMN series_key TEXT");
|
||||
alter("ALTER TABLE media_items ADD COLUMN duration_seconds REAL");
|
||||
alter("ALTER TABLE media_items ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'pending'");
|
||||
alter("ALTER TABLE media_items ADD COLUMN scan_error TEXT");
|
||||
alter("ALTER TABLE media_items ADD COLUMN last_scanned_at TEXT");
|
||||
alter("CREATE INDEX IF NOT EXISTS idx_media_items_series_key ON media_items(series_key)");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,7 +96,6 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
CREATE INDEX IF NOT EXISTS idx_review_plans_status ON review_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_plans_is_noop ON review_plans(is_noop);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_decisions_plan_id ON stream_decisions(plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_series_key ON media_items(series_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_series_name ON media_items(series_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_type ON media_items(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_streams_item_id ON media_streams(item_id);
|
||||
|
||||
+1
-4
@@ -8,8 +8,7 @@ import reviewRoutes from "./api/review";
|
||||
import scanRoutes from "./api/scan";
|
||||
import settingsRoutes from "./api/settings";
|
||||
import { getDb } from "./db/index";
|
||||
import { log, error as logError } from "./lib/log";
|
||||
import { startMqttClient } from "./services/mqtt";
|
||||
import { log } from "./lib/log";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -67,8 +66,6 @@ log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
|
||||
|
||||
getDb();
|
||||
|
||||
startMqttClient().catch((err) => logError("MQTT bootstrap failed:", err));
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -12,7 +12,6 @@ function stream(o: StreamOverride): MediaStream {
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -514,7 +513,7 @@ describe("analyzeItem — auto_class classification", () => {
|
||||
test("orig_lang_source=jellyfin is not authoritative → manual", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "jellyfin", needs_review: 0 },
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "probe", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -36,28 +35,23 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
|
||||
|
||||
const ITEM: MediaItem = {
|
||||
id: 1,
|
||||
jellyfin_id: "x",
|
||||
type: "Movie",
|
||||
name: "Test",
|
||||
original_title: null,
|
||||
series_name: null,
|
||||
series_jellyfin_id: null,
|
||||
series_key: null,
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
year: null,
|
||||
file_path: "/movies/Test.mkv",
|
||||
file_size: null,
|
||||
container: "mkv",
|
||||
runtime_ticks: null,
|
||||
date_last_refreshed: null,
|
||||
duration_seconds: null,
|
||||
original_language: "eng",
|
||||
orig_lang_source: "jellyfin",
|
||||
orig_lang_source: "probe",
|
||||
needs_review: 0,
|
||||
imdb_id: null,
|
||||
tmdb_id: null,
|
||||
tvdb_id: null,
|
||||
jellyfin_raw: null,
|
||||
external_raw: null,
|
||||
scan_status: "scanned",
|
||||
scan_error: null,
|
||||
last_scanned_at: null,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { JellyfinItem, JellyfinMediaStream } from "../../types";
|
||||
import { extractOriginalLanguage } from "../jellyfin";
|
||||
|
||||
function audio(o: Partial<JellyfinMediaStream>): JellyfinMediaStream {
|
||||
return { Type: "Audio", Index: 0, ...o };
|
||||
}
|
||||
|
||||
function item(streams: JellyfinMediaStream[]): JellyfinItem {
|
||||
return { Id: "x", Type: "Movie", Name: "Test", MediaStreams: streams };
|
||||
}
|
||||
|
||||
describe("extractOriginalLanguage — Jellyfin heuristic", () => {
|
||||
test("returns null when there are no audio streams", () => {
|
||||
expect(extractOriginalLanguage(item([{ Type: "Video", Index: 0 }]))).toBe(null);
|
||||
});
|
||||
|
||||
test("uses the only audio track when there is just one", () => {
|
||||
expect(extractOriginalLanguage(item([audio({ Language: "eng" })]))).toBe("eng");
|
||||
});
|
||||
|
||||
test("prefers the IsDefault audio track over position", () => {
|
||||
// 8 Mile regression: Turkish dub first, English default further down.
|
||||
// Old heuristic took the first track and labelled the movie Turkish.
|
||||
const streams = [audio({ Index: 0, Language: "tur" }), audio({ Index: 1, Language: "eng", IsDefault: true })];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("skips a dub even when it is the default", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", IsDefault: true, Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "eng" }),
|
||||
];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("falls back to first audio track when every track looks like a dub", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "deu", Title: "German Dub" }),
|
||||
];
|
||||
// No good candidate — returns the first audio so there's *some* guess,
|
||||
// but scan.ts is responsible for marking this needs_review.
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("tur");
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import type { JellyfinItem } from "../../types";
|
||||
import type { JellyfinConfig } from "../jellyfin";
|
||||
import type { RescanConfig } from "../rescan";
|
||||
import { _resetDedupe, processWebhookEvent } from "../webhook";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
|
||||
const RESCAN_CFG: RescanConfig = {};
|
||||
|
||||
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
||||
return {
|
||||
Id: "jf-1",
|
||||
Type: "Movie",
|
||||
Name: "Test Movie",
|
||||
Path: "/movies/Test.mkv",
|
||||
Container: "mkv",
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processWebhookEvent — acceptance", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
afterEach(() => _resetDedupe());
|
||||
|
||||
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
|
||||
const db = makeDb();
|
||||
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("NotificationType");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-Movie/Episode types", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Trailer" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemType");
|
||||
});
|
||||
|
||||
test("rejects missing ItemId", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemId");
|
||||
});
|
||||
|
||||
test("dedupes bursts within 5s and accepts again after", async () => {
|
||||
const db = makeDb();
|
||||
let fakeNow = 1_000_000;
|
||||
const getItemFn = async () => fakeItem();
|
||||
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
|
||||
|
||||
const first = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(first.accepted).toBe(true);
|
||||
|
||||
fakeNow += 1000;
|
||||
const second = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(second.accepted).toBe(false);
|
||||
expect(second.reason).toBe("deduped");
|
||||
|
||||
fakeNow += 5001;
|
||||
const third = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(third.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test("drops when Jellyfin returns no item", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("no item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processWebhookEvent — done-status override", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
|
||||
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
||||
return processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
|
||||
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
||||
);
|
||||
}
|
||||
|
||||
function planStatusFor(db: Database, jellyfinId: string): string {
|
||||
return (
|
||||
db
|
||||
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
|
||||
.get(jellyfinId) as { status: string }
|
||||
).status;
|
||||
}
|
||||
|
||||
test("a webhook reopens a done plan back to pending (will be re-processed)", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
||||
});
|
||||
|
||||
test("a scan (non-webhook) leaves a done plan as done", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
// Simulate a rescan (source='scan') via direct upsertJellyfinItem
|
||||
const { upsertJellyfinItem } = await import("../rescan");
|
||||
await upsertJellyfinItem(db, fresh, {}, { source: "scan" });
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
|
||||
function itemsBaseUrl(cfg: JellyfinConfig): string {
|
||||
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
"X-Emby-Token": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/Users`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = [
|
||||
"MediaStreams",
|
||||
"Path",
|
||||
"ProviderIds",
|
||||
"SeriesProviderIds",
|
||||
"OriginalTitle",
|
||||
"ProductionYear",
|
||||
"Size",
|
||||
"Container",
|
||||
"RunTimeTicks",
|
||||
"DateLastRefreshed",
|
||||
].join(",");
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void,
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set("Recursive", "true");
|
||||
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
url.searchParams.set("Limit", String(PAGE_SIZE));
|
||||
url.searchParams.set("StartIndex", String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
|
||||
for (const item of body.Items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
startIndex += body.Items.length;
|
||||
onProgress?.(startIndex, total);
|
||||
} while (startIndex < total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev mode: yields 50 random movies + all episodes from 10 random series.
|
||||
* Used instead of getAllItems() when NODE_ENV=development.
|
||||
*/
|
||||
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||
// 50 random movies
|
||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||
movieUrl.searchParams.set("Recursive", "true");
|
||||
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
|
||||
movieUrl.searchParams.set("SortBy", "Random");
|
||||
movieUrl.searchParams.set("Limit", "50");
|
||||
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!movieRes.ok)
|
||||
throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const item of movieBody.Items) yield item;
|
||||
|
||||
// 10 random series → yield all their episodes
|
||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||
seriesUrl.searchParams.set("Recursive", "true");
|
||||
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
|
||||
seriesUrl.searchParams.set("SortBy", "Random");
|
||||
seriesUrl.searchParams.set("Limit", "10");
|
||||
|
||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||
for (const series of seriesBody.Items) {
|
||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||
epUrl.searchParams.set("ParentId", series.Id);
|
||||
epUrl.searchParams.set("Recursive", "true");
|
||||
epUrl.searchParams.set("IncludeItemTypes", "Episode");
|
||||
epUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (epRes.ok) {
|
||||
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const ep of epBody.Items) yield ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch all episodes for a series by its Jellyfin series ID. */
|
||||
export async function getSeriesEpisodes(cfg: JellyfinConfig, seriesJellyfinId: string): Promise<JellyfinItem[]> {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set("ParentId", seriesJellyfinId);
|
||||
url.searchParams.set("Recursive", "true");
|
||||
url.searchParams.set("IncludeItemTypes", "Episode");
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { Items: JellyfinItem[] };
|
||||
return body.Items;
|
||||
}
|
||||
|
||||
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
|
||||
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
|
||||
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<JellyfinItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
|
||||
* Polls DateLastRefreshed until it changes (or timeout is reached).
|
||||
*/
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh and poll until the item's
|
||||
* `DateLastRefreshed` advances. Returns true when the probe actually ran;
|
||||
* false on timeout (caller decides whether to trust the item's current
|
||||
* metadata or treat it as unverified — verification paths should NOT
|
||||
* proceed on false, since a stale snapshot would give a bogus verdict).
|
||||
*/
|
||||
export async function refreshItem(
|
||||
cfg: JellyfinConfig,
|
||||
jellyfinId: string,
|
||||
timeoutMs = 15000,
|
||||
): Promise<{ refreshed: boolean }> {
|
||||
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
|
||||
|
||||
// 1. Snapshot current DateLastRefreshed
|
||||
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
|
||||
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
|
||||
const beforeDate = before.DateLastRefreshed;
|
||||
|
||||
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
|
||||
refreshUrl.searchParams.set("ImageRefreshMode", "None");
|
||||
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
|
||||
refreshUrl.searchParams.set("ReplaceAllImages", "false");
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
|
||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||
|
||||
// 3. Poll until DateLastRefreshed changes
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!checkRes.ok) continue;
|
||||
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
|
||||
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) {
|
||||
return { refreshed: true };
|
||||
}
|
||||
}
|
||||
return { refreshed: false };
|
||||
}
|
||||
|
||||
/** Case-insensitive hints that a track is a dub / commentary, not the original. */
|
||||
const DUB_TITLE_HINTS = /(dub|dubb|synchro|commentary|director)/i;
|
||||
|
||||
/**
|
||||
* Jellyfin has no real original_language field, so we guess from audio streams.
|
||||
* This is the notorious "8 Mile got labelled Turkish" heuristic — guard it:
|
||||
* 1. Prefer IsDefault audio when available (Jellyfin sets this from the file's
|
||||
* default disposition flag; uploaders usually set it to the original).
|
||||
* 2. Skip tracks whose title screams "dub" / "commentary".
|
||||
* 3. Fall back to the first non-dub audio track, then first audio track.
|
||||
* The caller must still treat any jellyfin-sourced value as unverified — this
|
||||
* just makes the guess less wrong. The trustworthy answer comes from Radarr/Sonarr.
|
||||
*/
|
||||
export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
if (!item.MediaStreams) return null;
|
||||
const audio = item.MediaStreams.filter((s) => s.Type === "Audio");
|
||||
if (audio.length === 0) return null;
|
||||
const notDub = (s: JellyfinMediaStream) => !s.Title || !DUB_TITLE_HINTS.test(s.Title);
|
||||
const pick = audio.find((s) => s.IsDefault && notDub(s)) ?? audio.find(notDub) ?? audio[0];
|
||||
return pick.Language ? normalizeLanguage(pick.Language) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id).
|
||||
*
|
||||
* NOTE: stores the raw `Language` value from Jellyfin (e.g. "en", "eng", "ger",
|
||||
* null). We intentionally do NOT normalize here because `is_noop` compares
|
||||
* raw → normalized to decide whether the pipeline should rewrite the tag to
|
||||
* canonical iso3. Callers that compare languages must use normalizeLanguage().
|
||||
*/
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream["type"],
|
||||
codec: s.Codec ?? null,
|
||||
profile: s.Profile ?? null,
|
||||
language: s.Language ?? null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
title: s.Title ?? null,
|
||||
is_default: s.IsDefault ? 1 : 0,
|
||||
is_forced: s.IsForced ? 1 : 0,
|
||||
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
|
||||
channels: s.Channels ?? null,
|
||||
channel_layout: s.ChannelLayout ?? null,
|
||||
bit_rate: s.BitRate ?? null,
|
||||
sample_rate: s.SampleRate ?? null,
|
||||
bit_depth: s.BitDepth ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export { normalizeLanguage } from "./language-utils";
|
||||
@@ -1,206 +0,0 @@
|
||||
import mqtt, { type MqttClient } from "mqtt";
|
||||
import { getConfig } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { handleWebhookMessage } from "./webhook";
|
||||
|
||||
export type MqttStatus = "connected" | "disconnected" | "error" | "not_configured";
|
||||
|
||||
interface MqttConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
let client: MqttClient | null = null;
|
||||
let currentStatus: MqttStatus = "not_configured";
|
||||
let currentError: string | null = null;
|
||||
const statusListeners = new Set<(status: MqttStatus, error: string | null) => void>();
|
||||
|
||||
export function getMqttStatus(): { status: MqttStatus; error: string | null } {
|
||||
return { status: currentStatus, error: currentError };
|
||||
}
|
||||
|
||||
export function onMqttStatus(fn: (status: MqttStatus, error: string | null) => void): () => void {
|
||||
statusListeners.add(fn);
|
||||
return () => {
|
||||
statusListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(next: MqttStatus, err: string | null = null): void {
|
||||
currentStatus = next;
|
||||
currentError = err;
|
||||
for (const l of statusListeners) l(next, err);
|
||||
}
|
||||
|
||||
function readConfig(): MqttConfig | null {
|
||||
if (getConfig("mqtt_enabled") !== "1") return null;
|
||||
const url = getConfig("mqtt_url") ?? "";
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
topic: getConfig("mqtt_topic") ?? "jellyfin/events",
|
||||
username: getConfig("mqtt_username") ?? "",
|
||||
password: getConfig("mqtt_password") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the configured MQTT broker and subscribe to the webhook topic.
|
||||
* Safe to call repeatedly: an existing client is torn down first. When no
|
||||
* broker is configured, status is set to 'not_configured' and the call is
|
||||
* a no-op.
|
||||
*/
|
||||
export async function startMqttClient(): Promise<void> {
|
||||
await stopMqttClient();
|
||||
const cfg = readConfig();
|
||||
if (!cfg) {
|
||||
setStatus("not_configured");
|
||||
return;
|
||||
}
|
||||
|
||||
log(`MQTT: connecting to ${cfg.url} (topic=${cfg.topic})`);
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 15_000,
|
||||
clientId: `netfelix-audio-fix-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
client = c;
|
||||
|
||||
c.on("connect", () => {
|
||||
c.subscribe(cfg.topic, { qos: 0 }, (err) => {
|
||||
if (err) {
|
||||
logError(`MQTT subscribe to ${cfg.topic} failed:`, err);
|
||||
setStatus("error", String(err));
|
||||
return;
|
||||
}
|
||||
log(`MQTT: connected, subscribed to ${cfg.topic}`);
|
||||
setStatus("connected");
|
||||
});
|
||||
});
|
||||
|
||||
c.on("reconnect", () => {
|
||||
setStatus("disconnected", "reconnecting");
|
||||
});
|
||||
|
||||
c.on("close", () => {
|
||||
setStatus("disconnected", null);
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
warn(`MQTT error: ${String(err)}`);
|
||||
setStatus("error", String(err));
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
const text = payload.toString("utf8");
|
||||
// Best-effort: the handler owns its own error handling. Don't let a
|
||||
// single malformed message tear the subscriber down.
|
||||
handleWebhookMessage(text).catch((err) => logError("webhook handler threw:", err));
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopMqttClient(): Promise<void> {
|
||||
if (!client) return;
|
||||
const c = client;
|
||||
client = null;
|
||||
await new Promise<void>((resolve) => {
|
||||
c.end(false, {}, () => resolve());
|
||||
});
|
||||
setStatus("not_configured");
|
||||
}
|
||||
|
||||
export interface MqttTestResult {
|
||||
brokerConnected: boolean;
|
||||
jellyfinTriggered: boolean;
|
||||
receivedMessage: boolean;
|
||||
itemName?: string;
|
||||
expectedItemId?: string;
|
||||
samplePayload?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end test of the MQTT loop: connect to the broker, subscribe to the
|
||||
* topic, ask Jellyfin to refresh a known item, and wait for the plugin to
|
||||
* publish a matching event. A pass proves the whole chain is wired up —
|
||||
* broker creds, Jellyfin webhook plugin config, and network reachability
|
||||
* between Jellyfin and broker.
|
||||
*
|
||||
* `triggerRefresh` is async and returns the Jellyfin item id we're waiting
|
||||
* for (so we can match only messages about that item and ignore unrelated
|
||||
* traffic). When null, we fall back to "any message on the topic" mode —
|
||||
* useful before the library is scanned.
|
||||
*/
|
||||
export async function testMqttConnection(
|
||||
cfg: MqttConfig,
|
||||
triggerRefresh: () => Promise<{ itemId: string; itemName: string } | null>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<MqttTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 0,
|
||||
connectTimeout: 10_000,
|
||||
clientId: `netfelix-audio-fix-test-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let expectedItemId: string | null = null;
|
||||
let itemName: string | undefined;
|
||||
let jellyfinTriggered = false;
|
||||
let brokerConnected = false;
|
||||
const done = (result: Omit<MqttTestResult, "expectedItemId" | "jellyfinTriggered" | "brokerConnected">) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
c.end(true);
|
||||
resolve({
|
||||
brokerConnected,
|
||||
jellyfinTriggered,
|
||||
expectedItemId: expectedItemId ?? undefined,
|
||||
itemName,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
c.on("connect", () => {
|
||||
brokerConnected = true;
|
||||
c.subscribe(cfg.topic, { qos: 0 }, async (err) => {
|
||||
if (err) {
|
||||
done({ receivedMessage: false, error: `subscribe: ${String(err)}` });
|
||||
return;
|
||||
}
|
||||
// Subscribed. Trigger the Jellyfin refresh so the webhook has
|
||||
// something concrete to publish.
|
||||
try {
|
||||
const trigger = await triggerRefresh();
|
||||
if (trigger) {
|
||||
expectedItemId = trigger.itemId;
|
||||
itemName = trigger.itemName;
|
||||
jellyfinTriggered = true;
|
||||
}
|
||||
} catch (triggerErr) {
|
||||
done({ receivedMessage: false, error: `jellyfin trigger: ${String(triggerErr)}` });
|
||||
return;
|
||||
}
|
||||
});
|
||||
setTimeout(() => done({ receivedMessage: false }), timeoutMs);
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
// Any message on the configured topic is enough — a rescan of an
|
||||
// unchanged item won't fire Item Added, so the "itemId matches"
|
||||
// filter would cause false failures. The user triggers real
|
||||
// activity in Jellyfin if the auto-rescan doesn't wake anything.
|
||||
done({ receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 400) });
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
done({ receivedMessage: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { log, warn } from "../lib/log";
|
||||
import { getItem, type JellyfinConfig } from "./jellyfin";
|
||||
import { type RescanConfig, type RescanResult, upsertJellyfinItem } from "./rescan";
|
||||
|
||||
/**
|
||||
* Events we care about. Jellyfin's Webhook plugin (jellyfin-plugin-webhook)
|
||||
* only exposes ItemAdded as a library-side notification — there is no
|
||||
* ItemUpdated or Library.ItemUpdated. File-rewrites on existing items
|
||||
* produce zero MQTT traffic, so we can't observe them here; the UI's
|
||||
* post-job verification runs off our own ffprobe instead.
|
||||
*
|
||||
* Payload fields are PascalCase (NotificationType, ItemId, ItemType) — the
|
||||
* earlier camelCase in this handler matched nothing the plugin ever sends.
|
||||
*/
|
||||
const ACCEPTED_EVENTS = new Set(["ItemAdded"]);
|
||||
const ACCEPTED_TYPES = new Set(["Movie", "Episode"]);
|
||||
|
||||
/** 5-second dedupe window: Jellyfin can fire the same ItemAdded twice when multiple libraries share a path. */
|
||||
const DEDUPE_WINDOW_MS = 5000;
|
||||
const dedupe = new Map<string, number>();
|
||||
|
||||
|
||||
export interface WebhookPayload {
|
||||
NotificationType?: string;
|
||||
ItemId?: string;
|
||||
ItemType?: string;
|
||||
}
|
||||
|
||||
export interface WebhookHandlerDeps {
|
||||
db: Database;
|
||||
jellyfin: JellyfinConfig;
|
||||
rescanCfg: RescanConfig;
|
||||
getItemFn?: typeof getItem;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
accepted: boolean;
|
||||
reason?: string;
|
||||
result?: RescanResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an incoming webhook payload and, if it describes a relevant Jellyfin
|
||||
* library event for a Movie/Episode, re-analyze the item and let rescan's
|
||||
* webhook-override flip stale 'done' plans back to 'pending'.
|
||||
*
|
||||
* Errors from Jellyfin are logged and swallowed: one bad message must not
|
||||
* take down the MQTT subscriber.
|
||||
*/
|
||||
export async function processWebhookEvent(payload: WebhookPayload, deps: WebhookHandlerDeps): Promise<WebhookResult> {
|
||||
const { db, jellyfin, rescanCfg, getItemFn = getItem, now = Date.now } = deps;
|
||||
|
||||
if (!payload.NotificationType || !ACCEPTED_EVENTS.has(payload.NotificationType)) {
|
||||
return { accepted: false, reason: `NotificationType '${payload.NotificationType}' not accepted` };
|
||||
}
|
||||
if (!payload.ItemType || !ACCEPTED_TYPES.has(payload.ItemType)) {
|
||||
return { accepted: false, reason: `ItemType '${payload.ItemType}' not accepted` };
|
||||
}
|
||||
if (!payload.ItemId) {
|
||||
return { accepted: false, reason: "missing ItemId" };
|
||||
}
|
||||
|
||||
// Debounce: drop bursts within the window, always evict stale entries.
|
||||
const ts = now();
|
||||
for (const [id, seen] of dedupe) {
|
||||
if (ts - seen > DEDUPE_WINDOW_MS) dedupe.delete(id);
|
||||
}
|
||||
const last = dedupe.get(payload.ItemId);
|
||||
if (last != null && ts - last <= DEDUPE_WINDOW_MS) {
|
||||
return { accepted: false, reason: "deduped" };
|
||||
}
|
||||
dedupe.set(payload.ItemId, ts);
|
||||
|
||||
const fresh = await getItemFn(jellyfin, payload.ItemId);
|
||||
if (!fresh) {
|
||||
warn(`Webhook: Jellyfin returned no item for ${payload.ItemId}`);
|
||||
return { accepted: false, reason: "jellyfin returned no item" };
|
||||
}
|
||||
|
||||
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
|
||||
log(`Webhook: ingested ${payload.ItemType} ${payload.ItemId} is_noop=${result.isNoop}`);
|
||||
return { accepted: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT-facing adapter: parses the raw payload text, pulls config, calls
|
||||
* processWebhookEvent. Exposed so server/services/mqtt.ts can stay purely
|
||||
* about transport, and tests can drive the logic without spinning up MQTT.
|
||||
*/
|
||||
export async function handleWebhookMessage(rawPayload: string): Promise<WebhookResult> {
|
||||
let payload: WebhookPayload;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch (err) {
|
||||
warn(`Webhook: malformed JSON payload: ${String(err)}`);
|
||||
return { accepted: false, reason: "malformed JSON" };
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfin: JellyfinConfig = {
|
||||
url: cfg.jellyfin_url,
|
||||
apiKey: cfg.jellyfin_api_key,
|
||||
userId: cfg.jellyfin_user_id,
|
||||
};
|
||||
if (!jellyfin.url || !jellyfin.apiKey) {
|
||||
return { accepted: false, reason: "jellyfin not configured" };
|
||||
}
|
||||
|
||||
const rescanCfg: RescanConfig = {};
|
||||
|
||||
return processWebhookEvent(payload, { db: getDb(), jellyfin, rescanCfg });
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export function _resetDedupe(): void {
|
||||
dedupe.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user