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>
160 lines
6.8 KiB
TypeScript
160 lines
6.8 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { DEFAULT_CONFIG, SCHEMA } from "./schema";
|
|
|
|
const dataDir = process.env.DATA_DIR ?? "./data";
|
|
mkdirSync(dataDir, { recursive: true });
|
|
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
|
|
|
|
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
|
|
|
const ENV_MAP: Record<string, string> = {
|
|
radarr_url: "RADARR_URL",
|
|
radarr_api_key: "RADARR_API_KEY",
|
|
radarr_enabled: "RADARR_ENABLED",
|
|
sonarr_url: "SONARR_URL",
|
|
sonarr_api_key: "SONARR_API_KEY",
|
|
sonarr_enabled: "SONARR_ENABLED",
|
|
audio_languages: "AUDIO_LANGUAGES",
|
|
};
|
|
|
|
/** Read a config key from environment variables (returns null if not set). */
|
|
function envValue(key: string): string | null {
|
|
const envKey = ENV_MAP[key];
|
|
if (!envKey) return null;
|
|
const val = process.env[envKey];
|
|
if (!val) return null;
|
|
if (key.endsWith("_enabled")) return val === "1" || val.toLowerCase() === "true" ? "1" : "0";
|
|
if (key === "audio_languages") return JSON.stringify(val.split(",").map((s) => s.trim()));
|
|
if (key.endsWith("_url")) return val.replace(/\/$/, "");
|
|
return val;
|
|
}
|
|
|
|
/** True when env vars are configured enough to skip the setup wizard. */
|
|
function isEnvConfigured(): boolean {
|
|
return !!(process.env.MOVIES_ROOT || process.env.TV_ROOT);
|
|
}
|
|
|
|
// ─── Database ──────────────────────────────────────────────────────────────────
|
|
|
|
let _db: Database | null = null;
|
|
|
|
export function getDb(): Database {
|
|
if (_db) return _db;
|
|
_db = new Database(dbPath, { create: true });
|
|
_db.exec(SCHEMA);
|
|
migrate(_db);
|
|
backfill(_db);
|
|
seedDefaults(_db);
|
|
return _db;
|
|
}
|
|
|
|
/**
|
|
* Idempotent ALTER TABLE migrations for columns added after the initial
|
|
* CREATE TABLE ships. Each block swallows "duplicate column" errors so the
|
|
* same code path is safe on fresh and existing databases. Do not remove old
|
|
* migrations — databases in the wild may be several versions behind.
|
|
*/
|
|
function migrate(db: Database): void {
|
|
const alter = (sql: string) => {
|
|
try {
|
|
db.exec(sql);
|
|
} catch (_err) {
|
|
// column already present — ignore
|
|
}
|
|
};
|
|
alter("ALTER TABLE review_plans ADD COLUMN webhook_verified INTEGER NOT NULL DEFAULT 0");
|
|
// 2026-04-14: renamed webhook_verified → verified once we realized the
|
|
// signal would come from our own ffprobe, not from a Jellyfin webhook.
|
|
// RENAME COLUMN preserves values; both alters are no-ops on fresh DBs.
|
|
alter("ALTER TABLE review_plans RENAME COLUMN webhook_verified TO verified");
|
|
alter("ALTER TABLE review_plans DROP COLUMN verified");
|
|
alter("ALTER TABLE media_items ADD COLUMN ingest_source TEXT NOT NULL DEFAULT 'scan'");
|
|
alter("ALTER TABLE review_plans ADD COLUMN auto_class TEXT");
|
|
alter("ALTER TABLE review_plans ADD COLUMN sorted INTEGER NOT NULL DEFAULT 0");
|
|
alter("ALTER TABLE review_plans DROP COLUMN confidence");
|
|
// Per-stream language override — lets the user correct an "und" (or
|
|
// mislabeled) audio track without round-tripping through Jellyfin. Read
|
|
// in preference to stream.language by the analyzer and the ffmpeg
|
|
// command builder; preserved across reanalyze and rescan like custom_title.
|
|
alter("ALTER TABLE stream_decisions ADD COLUMN custom_language TEXT");
|
|
// 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)");
|
|
}
|
|
|
|
/**
|
|
* One-shot backfill for the inbox/auto_class rollout (2026-04-18):
|
|
*
|
|
* - Existing plans were already past the Inbox stage in the old world. Set
|
|
* sorted = 1 on every pre-existing row so they keep showing up where the
|
|
* user last saw them; dumping them into the new Inbox column on upgrade
|
|
* would look like data loss.
|
|
* - auto_class starts NULL on upgraded rows. The analyzer's next run over
|
|
* each item (reanalyze, rescan, or the explicit /sort-inbox pass) will
|
|
* populate it. Until then, the Review column renders a neutral badge.
|
|
*
|
|
* Idempotent: the WHERE clause makes repeated calls no-ops.
|
|
*/
|
|
function backfill(db: Database): void {
|
|
db.prepare("UPDATE review_plans SET sorted = 1 WHERE sorted = 0 AND auto_class IS NULL").run();
|
|
}
|
|
|
|
function seedDefaults(db: Database): void {
|
|
const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
|
|
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
|
insert.run(key, value);
|
|
}
|
|
}
|
|
|
|
/** Re-seed config defaults after a truncating reset. Caller owns the delete. */
|
|
export function reseedDefaults(): void {
|
|
seedDefaults(getDb());
|
|
}
|
|
|
|
export function getConfig(key: string): string | null {
|
|
// Env vars take precedence over DB
|
|
const fromEnv = envValue(key);
|
|
if (fromEnv !== null) return fromEnv;
|
|
// Auto-complete setup when all required Jellyfin env vars are present
|
|
if (key === "setup_complete" && isEnvConfigured()) return "1";
|
|
const row = getDb().prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
export function setConfig(key: string, value: string): void {
|
|
getDb().prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run(key, value);
|
|
}
|
|
|
|
/** Returns the set of config keys currently overridden by environment variables. */
|
|
export function getEnvLockedKeys(): Set<string> {
|
|
const locked = new Set<string>();
|
|
for (const key of Object.keys(ENV_MAP)) {
|
|
if (envValue(key) !== null) locked.add(key);
|
|
}
|
|
return locked;
|
|
}
|
|
|
|
export function getAllConfig(): Record<string, string> {
|
|
const rows = getDb().prepare("SELECT key, value FROM config").all() as { key: string; value: string }[];
|
|
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ""]));
|
|
// Apply env overrides on top of DB values
|
|
for (const key of Object.keys(ENV_MAP)) {
|
|
const fromEnv = envValue(key);
|
|
if (fromEnv !== null) result[key] = fromEnv;
|
|
}
|
|
// Auto-complete setup when all required Jellyfin env vars are present
|
|
if (isEnvConfigured()) result.setup_complete = "1";
|
|
return result;
|
|
}
|