Files
netfelix-audio-fix/server/db/index.ts
T
felixfoertsch 8112bfeb65
Build and Push Docker Image / build (push) Successful in 3m3s
per-track language override on audio detail page
adds stream_decisions.custom_language (ISO 639-2 code or null) so the
user can correct a mislabeled audio track — e.g. a Spanish dub tagged
"und" in the container — without going through Jellyfin. the override
wins over stream.language everywhere it matters: the analyzer reads it
for keep/remove decisions and track ordering, the ffmpeg command builder
writes it as both the language metadata tag and the harmonized track
title, and reanalyze preserves it across reruns and rescans.

on the audio detail page, each pending audio row swaps its language
cell for an inline <select> populated from LANG_NAMES. picking the raw
file language clears the override; anything else sets it and triggers a
server-side reanalyze so keep/remove + target_index update immediately.
a small ✎ hint marks overridden tracks. rebuilt commands tag the output
accordingly so Jellyfin reads the corrected language.

PATCH /api/review/:id/stream/:streamId/language validates the code
against LANG_NAMES (accepts ISO 639-1/2/2B aliases, rejects garbage)
and runs reanalyze inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:05:31 +02:00

161 lines
6.5 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> = {
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",
sonarr_url: "SONARR_URL",
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). */
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 minimum required Jellyfin env vars are present — skips the setup wizard. */
function isEnvConfigured(): boolean {
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
}
// ─── 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)");
}
/**
* 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;
}