50e1ea66f4
Build and Push Docker Image / build (push) Successful in 1m1s
Analyzer now computes structured reason tags (Remove tracks, Reorder, Extract subs, Transcode, Fix default, Fix language tag, Fix title) and stores them as JSON in review_plans.reasons. Pipeline cards show these as badges next to the copy/transcode pill so users know why a file needs processing. Replaces the old transcode_reasons computed from stream_decisions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
154 lines
6.6 KiB
TypeScript
154 lines
6.6 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);
|
|
seedDefaults(_db);
|
|
// Clear stale flags/jobs from a previous container that was killed mid-operation.
|
|
_db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running' AND value = '1'").run();
|
|
_db.prepare("UPDATE jobs SET status = 'error', completed_at = datetime('now') WHERE status = 'running'").run();
|
|
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)");
|
|
alter("ALTER TABLE review_plans ADD COLUMN reasons TEXT");
|
|
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
|
|
// drop-jellyfin: if old schema detected, wipe all tables so SCHEMA
|
|
// recreates them with the new structure (file_path UNIQUE, no jellyfin columns).
|
|
// Data must be rescanned anyway since the source changed from Jellyfin to ffprobe.
|
|
const hasJellyfinId = db
|
|
.prepare("SELECT COUNT(*) as n FROM pragma_table_info('media_items') WHERE name = 'jellyfin_id'")
|
|
.get() as { n: number };
|
|
if (hasJellyfinId.n > 0) {
|
|
db.exec("DROP TABLE IF EXISTS jobs");
|
|
db.exec("DROP TABLE IF EXISTS stream_decisions");
|
|
db.exec("DROP TABLE IF EXISTS review_plans");
|
|
db.exec("DROP TABLE IF EXISTS media_streams");
|
|
db.exec("DROP TABLE IF EXISTS media_items");
|
|
db.exec(SCHEMA);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|