Files
ripster/backend/src/db/database.js
T
2026-04-29 08:35:24 +00:00

1455 lines
64 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
const { dbPath } = require('../config');
const logger = require('../services/logger').child('DB');
const { errorToMeta } = require('../utils/errorMeta');
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');
const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
const LEGACY_PROFILE_SETTING_MIGRATIONS = [
{
legacyKey: 'mediainfo_extra_args',
profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd']
},
{
legacyKey: 'makemkv_rip_mode',
profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd']
},
{
legacyKey: 'makemkv_analyze_extra_args',
profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd']
},
{
legacyKey: 'makemkv_rip_extra_args',
profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd']
},
{
legacyKey: 'handbrake_preset',
profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd']
},
{
legacyKey: 'handbrake_extra_args',
profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd']
},
{
legacyKey: 'output_extension',
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
},
{
legacyKey: 'output_template',
profileKeys: ['output_template_bluray', 'output_template_dvd']
}
];
const INSTALL_PATH_SETTING_DEFAULTS = [
{
key: 'log_dir',
pathParts: ['logs'],
legacyDefaults: ['data/logs', './data/logs']
}
];
let dbInstance;
function nowFileStamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
function isSqliteCorruptionError(error) {
if (!error) {
return false;
}
const code = String(error.code || '').toUpperCase();
const msg = String(error.message || '').toLowerCase();
return (
code === 'SQLITE_CORRUPT' ||
msg.includes('database disk image is malformed') ||
msg.includes('file is not a database')
);
}
function moveIfExists(sourcePath, targetPath) {
if (!fs.existsSync(sourcePath)) {
return false;
}
fs.renameSync(sourcePath, targetPath);
return true;
}
function quarantineCorruptDatabaseFiles() {
const dir = path.dirname(dbPath);
const base = path.basename(dbPath);
const stamp = nowFileStamp();
const archiveDir = path.join(dir, 'corrupt-backups');
fs.mkdirSync(archiveDir, { recursive: true });
const moved = [];
const candidates = [
dbPath,
`${dbPath}-wal`,
`${dbPath}-shm`
];
for (const sourcePath of candidates) {
const fileName = path.basename(sourcePath);
const targetPath = path.join(archiveDir, `${fileName}.${stamp}.corrupt`);
if (moveIfExists(sourcePath, targetPath)) {
moved.push({
from: sourcePath,
to: targetPath
});
}
}
logger.warn('recovery:quarantine-complete', {
dbPath,
base,
movedCount: moved.length,
moved
});
}
function quoteIdentifier(identifier) {
return `"${String(identifier || '').replace(/"/g, '""')}"`;
}
function normalizeSqlType(value) {
return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase();
}
function normalizeDefault(value) {
if (value === null || value === undefined) {
return '';
}
return String(value).trim().replace(/\s+/g, ' ').toUpperCase();
}
function sameTableShape(current = [], desired = []) {
if (current.length !== desired.length) {
return false;
}
for (let i = 0; i < current.length; i += 1) {
const left = current[i];
const right = desired[i];
if (!left || !right) {
return false;
}
if (String(left.name || '') !== String(right.name || '')) {
return false;
}
if (normalizeSqlType(left.type) !== normalizeSqlType(right.type)) {
return false;
}
if (Number(left.notnull || 0) !== Number(right.notnull || 0)) {
return false;
}
if (Number(left.pk || 0) !== Number(right.pk || 0)) {
return false;
}
if (normalizeDefault(left.dflt_value) !== normalizeDefault(right.dflt_value)) {
return false;
}
}
return true;
}
function sameForeignKeys(current = [], desired = []) {
if (current.length !== desired.length) {
return false;
}
for (let i = 0; i < current.length; i += 1) {
const left = current[i];
const right = desired[i];
if (!left || !right) {
return false;
}
if (Number(left.id || 0) !== Number(right.id || 0)) {
return false;
}
if (Number(left.seq || 0) !== Number(right.seq || 0)) {
return false;
}
if (String(left.table || '') !== String(right.table || '')) {
return false;
}
if (String(left.from || '') !== String(right.from || '')) {
return false;
}
if (String(left.to || '') !== String(right.to || '')) {
return false;
}
if (String(left.on_update || '') !== String(right.on_update || '')) {
return false;
}
if (String(left.on_delete || '') !== String(right.on_delete || '')) {
return false;
}
if (String(left.match || '') !== String(right.match || '')) {
return false;
}
}
return true;
}
async function tableExists(db, tableName) {
const row = await db.get(
`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
[tableName]
);
return Boolean(row);
}
async function getTableInfo(db, tableName) {
return db.all(`PRAGMA table_info(${quoteIdentifier(tableName)})`);
}
async function getForeignKeyInfo(db, tableName) {
return db.all(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`);
}
async function readConfiguredLogDirSetting(db) {
const hasSchemaTable = await tableExists(db, 'settings_schema');
const hasValuesTable = await tableExists(db, 'settings_values');
if (!hasSchemaTable || !hasValuesTable) {
return null;
}
try {
const row = await db.get(
`
SELECT
COALESCE(v.value, s.default_value, '') AS value
FROM settings_schema s
LEFT JOIN settings_values v ON v.key = s.key
WHERE s.key = ?
LIMIT 1
`,
['log_dir']
);
const value = String(row?.value || '').trim();
return value || null;
} catch (error) {
logger.warn('log-root:read-setting-failed', {
error: error?.message || String(error)
});
return null;
}
}
async function configureRuntimeLogRootFromSettings(db, options = {}) {
const ensure = Boolean(options.ensure);
const configured = await readConfiguredLogDirSetting(db);
let resolved = setLogRootDir(configured);
if (ensure) {
try {
fs.mkdirSync(resolved, { recursive: true });
} catch (error) {
const fallbackResolved = setLogRootDir(null);
try {
fs.mkdirSync(fallbackResolved, { recursive: true });
} catch (_fallbackError) {
// ignored: logger itself is hardened and may still write to console only
}
logger.warn('log-root:ensure-failed', {
configured: configured || null,
resolved,
fallbackResolved,
error: error?.message || String(error)
});
resolved = fallbackResolved;
}
}
return {
configured,
resolved
};
}
async function loadSchemaModel() {
if (!fs.existsSync(schemaFilePath)) {
const error = new Error(`Schema-Datei fehlt: ${schemaFilePath}`);
error.code = 'SCHEMA_FILE_MISSING';
throw error;
}
const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
const memDb = await open({
filename: ':memory:',
driver: sqlite3.Database
});
try {
await memDb.exec(schemaSql);
const tables = await memDb.all(`
SELECT name, sql
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY rowid ASC
`);
const indexes = await memDb.all(`
SELECT name, tbl_name AS tableName, sql
FROM sqlite_master
WHERE type = 'index'
AND name NOT LIKE 'sqlite_%'
AND sql IS NOT NULL
ORDER BY rowid ASC
`);
const tableInfos = {};
const tableForeignKeys = {};
for (const table of tables) {
tableInfos[table.name] = await getTableInfo(memDb, table.name);
tableForeignKeys[table.name] = await getForeignKeyInfo(memDb, table.name);
}
return {
schemaSql,
tables,
indexes,
tableInfos,
tableForeignKeys
};
} finally {
await memDb.close();
}
}
async function rebuildTable(db, tableName, createSql) {
const oldName = `${tableName}__old_${Date.now()}`;
const tableNameQuoted = quoteIdentifier(tableName);
const oldNameQuoted = quoteIdentifier(oldName);
const beforeInfo = await getTableInfo(db, tableName);
await db.exec(`ALTER TABLE ${tableNameQuoted} RENAME TO ${oldNameQuoted}`);
await db.exec(createSql);
const afterInfo = await getTableInfo(db, tableName);
const beforeColumns = new Set(beforeInfo.map((column) => String(column.name)));
const commonColumns = afterInfo
.map((column) => String(column.name))
.filter((name) => beforeColumns.has(name));
if (commonColumns.length > 0) {
const columnList = commonColumns.map((name) => quoteIdentifier(name)).join(', ');
await db.exec(`
INSERT INTO ${tableNameQuoted} (${columnList})
SELECT ${columnList}
FROM ${oldNameQuoted}
`);
}
await db.exec(`DROP TABLE ${oldNameQuoted}`);
}
async function syncSchemaToModel(db, model) {
const desiredTables = Array.isArray(model?.tables) ? model.tables : [];
const desiredIndexes = Array.isArray(model?.indexes) ? model.indexes : [];
const desiredTableInfo = model?.tableInfos && typeof model.tableInfos === 'object'
? model.tableInfos
: {};
const desiredTableForeignKeys = model?.tableForeignKeys && typeof model.tableForeignKeys === 'object'
? model.tableForeignKeys
: {};
const currentTables = await db.all(`
SELECT name, sql
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY rowid ASC
`);
const currentByName = new Map(currentTables.map((table) => [table.name, table]));
const desiredTableNameSet = new Set(desiredTables.map((table) => table.name));
for (const table of desiredTables) {
const tableName = String(table.name || '');
const createSql = String(table.sql || '').trim();
if (!tableName || !createSql) {
continue;
}
if (!currentByName.has(tableName)) {
await db.exec(createSql);
logger.info('schema:create-table', { table: tableName });
continue;
}
const currentInfo = await getTableInfo(db, tableName);
const wantedInfo = Array.isArray(desiredTableInfo[tableName]) ? desiredTableInfo[tableName] : [];
const currentFks = await getForeignKeyInfo(db, tableName);
const wantedFks = Array.isArray(desiredTableForeignKeys[tableName]) ? desiredTableForeignKeys[tableName] : [];
const shapeMatches = sameTableShape(currentInfo, wantedInfo);
const foreignKeysMatch = sameForeignKeys(currentFks, wantedFks);
if (!shapeMatches || !foreignKeysMatch) {
await rebuildTable(db, tableName, createSql);
logger.warn('schema:rebuild-table', {
table: tableName,
reason: !shapeMatches ? 'shape-mismatch' : 'foreign-key-mismatch'
});
}
}
for (const table of currentTables) {
if (desiredTableNameSet.has(table.name)) {
continue;
}
await db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(table.name)}`);
logger.warn('schema:drop-table', { table: table.name });
}
const currentIndexes = await db.all(`
SELECT name, tbl_name AS tableName, sql
FROM sqlite_master
WHERE type = 'index'
AND name NOT LIKE 'sqlite_%'
AND sql IS NOT NULL
ORDER BY rowid ASC
`);
const desiredIndexNameSet = new Set(desiredIndexes.map((index) => index.name));
for (const index of currentIndexes) {
if (desiredIndexNameSet.has(index.name)) {
continue;
}
await db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(index.name)}`);
logger.warn('schema:drop-index', { index: index.name, table: index.tableName });
}
for (const index of desiredIndexes) {
let sql = String(index.sql || '').trim();
if (!sql) {
continue;
}
if (/^CREATE\s+UNIQUE\s+INDEX\s+/i.test(sql)) {
sql = sql.replace(/^CREATE\s+UNIQUE\s+INDEX\s+/i, 'CREATE UNIQUE INDEX IF NOT EXISTS ');
} else if (/^CREATE\s+INDEX\s+/i.test(sql)) {
sql = sql.replace(/^CREATE\s+INDEX\s+/i, 'CREATE INDEX IF NOT EXISTS ');
}
await db.exec(sql);
}
}
async function exportLegacyJobLogsToFiles(db) {
const hasJobLogsTable = await tableExists(db, 'job_logs');
if (!hasJobLogsTable) {
return;
}
const rows = await db.all(`
SELECT job_id, source, message, timestamp
FROM job_logs
ORDER BY job_id ASC, id ASC
`);
if (!Array.isArray(rows) || rows.length === 0) {
logger.info('legacy-job-logs:export:skip-empty');
return;
}
const targetDir = getJobLogDir();
fs.mkdirSync(targetDir, { recursive: true });
const streams = new Map();
try {
for (const row of rows) {
const jobId = Number(row?.job_id);
if (!Number.isFinite(jobId) || jobId <= 0) {
continue;
}
const key = String(Math.trunc(jobId));
if (!streams.has(key)) {
const filePath = path.join(targetDir, `job-${key}.process.log`);
const stream = fs.createWriteStream(filePath, {
flags: 'w',
encoding: 'utf-8'
});
streams.set(key, stream);
}
const line = `[${String(row?.timestamp || '')}] [${String(row?.source || 'SYSTEM')}] ${String(row?.message || '')}\n`;
streams.get(key).write(line);
}
} finally {
await Promise.all(
[...streams.values()].map(
(stream) =>
new Promise((resolve) => {
stream.end(resolve);
})
)
);
}
logger.warn('legacy-job-logs:exported', {
lines: rows.length,
jobs: streams.size,
targetDir
});
}
async function applySchemaModel(db, model) {
await db.exec('PRAGMA foreign_keys = OFF;');
await db.exec('BEGIN');
try {
await syncSchemaToModel(db, model);
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
} finally {
await db.exec('PRAGMA foreign_keys = ON;');
}
}
async function openAndPrepareDatabase() {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
logger.info('init:open', { dbPath });
dbInstance = await open({
filename: dbPath,
driver: sqlite3.Database
});
await dbInstance.exec('PRAGMA journal_mode = WAL;');
await dbInstance.exec('PRAGMA foreign_keys = ON;');
const initialLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
logger.info('log-root:initialized', {
configured: initialLogRoot.configured || null,
resolved: initialLogRoot.resolved
});
await exportLegacyJobLogsToFiles(dbInstance);
const schemaModel = await loadSchemaModel();
await applySchemaModel(dbInstance, schemaModel);
await seedFromSchemaFile(dbInstance);
await syncInstallPathSettingDefaults(dbInstance);
await migrateLegacyProfiledToolSettings(dbInstance);
await migrateOutputTemplates(dbInstance);
await removeDeprecatedSettings(dbInstance);
await migrateSettingsSchemaMetadata(dbInstance);
await ensurePipelineStateRow(dbInstance);
await backfillJobOutputFolders(dbInstance);
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
logger.info('log-root:synced', {
configured: syncedLogRoot.configured || null,
resolved: syncedLogRoot.resolved
});
logger.info('init:done');
return dbInstance;
}
async function initDatabase({ allowRecovery = true } = {}) {
if (dbInstance) {
return dbInstance;
}
try {
return await openAndPrepareDatabase();
} catch (error) {
logger.error('init:failed', { error: errorToMeta(error), allowRecovery });
if (dbInstance) {
try {
await dbInstance.close();
} catch (_closeError) {
// ignore close errors during failed init
}
dbInstance = undefined;
}
if (allowRecovery && isSqliteCorruptionError(error)) {
logger.warn('recovery:corrupt-db-detected', { dbPath });
quarantineCorruptDatabaseFiles();
return initDatabase({ allowRecovery: false });
}
throw error;
}
}
async function seedFromSchemaFile(db) {
const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
// Kommentarzeilen vor dem Split entfernen, damit der erste INSERT-Block nicht
// mit vorangehenden Kommentaren in einem Chunk landet und durch den
// /^INSERT\b/-Filter herausfällt.
const strippedSql = schemaSql.replace(/^--[^\n]*$/gm, '');
const statements = strippedSql
.split(/;\s*\n/)
.map((s) => s.trim())
.filter((s) => /^INSERT\b/i.test(s));
for (const stmt of statements) {
await db.run(stmt);
}
logger.info('seed:settings', { count: statements.length });
}
async function syncInstallPathSettingDefaults(db) {
const dataDir = path.dirname(dbPath);
const updates = INSTALL_PATH_SETTING_DEFAULTS.map((item) => ({
key: item.key,
value: path.join(dataDir, ...item.pathParts),
legacyDefaults: Array.isArray(item.legacyDefaults) ? item.legacyDefaults : []
}));
await db.exec('BEGIN');
try {
for (const update of updates) {
const placeholders = update.legacyDefaults.map(() => '?').join(', ');
await db.run(
`
UPDATE settings_schema
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ?
AND (
default_value IS NULL
OR TRIM(default_value) = ''
OR default_value IN (${placeholders})
)
`,
[update.value, update.key, ...update.legacyDefaults]
);
await db.run(
`
INSERT INTO settings_values (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO NOTHING
`,
[update.key, update.value]
);
await db.run(
`
UPDATE settings_values
SET value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ?
AND (
value IS NULL
OR TRIM(value) = ''
OR value IN (${placeholders})
)
`,
[update.value, update.key, ...update.legacyDefaults]
);
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
logger.info('seed:path-defaults-synced', {
dataDir,
settings: updates.map((item) => ({ key: item.key, value: item.value }))
});
}
async function readCurrentOrDefaultSettingValue(db, key) {
if (!key) {
return null;
}
return db.get(
`
SELECT
s.default_value AS defaultValue,
v.value AS currentValue,
COALESCE(v.value, s.default_value) AS effectiveValue
FROM settings_schema s
LEFT JOIN settings_values v ON v.key = s.key
WHERE s.key = ?
LIMIT 1
`,
[key]
);
}
async function migrateLegacyProfiledToolSettings(db) {
let copiedCount = 0;
for (const migration of LEGACY_PROFILE_SETTING_MIGRATIONS) {
const legacyRow = await readCurrentOrDefaultSettingValue(db, migration.legacyKey);
if (!legacyRow) {
continue;
}
for (const targetKey of migration.profileKeys || []) {
const targetRow = await readCurrentOrDefaultSettingValue(db, targetKey);
if (!targetRow) {
continue;
}
const currentValue = targetRow.currentValue;
const defaultValue = targetRow.defaultValue;
const shouldCopy = (
currentValue === null
|| currentValue === undefined
|| String(currentValue) === String(defaultValue ?? '')
);
if (!shouldCopy) {
continue;
}
await db.run(
`
INSERT INTO settings_values (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`,
[targetKey, legacyRow.effectiveValue ?? null]
);
copiedCount += 1;
logger.info('migrate:legacy-tool-setting-copied', {
from: migration.legacyKey,
to: targetKey
});
}
}
if (copiedCount > 0) {
logger.info('migrate:legacy-tool-settings:done', { copiedCount });
}
}
async function ensurePipelineStateRow(db) {
await db.run(
`
INSERT INTO pipeline_state (id, state, active_job_id, progress, eta, status_text, context_json)
VALUES (1, 'IDLE', NULL, 0, NULL, NULL, '{}')
ON CONFLICT(id) DO NOTHING
`
);
}
async function migrateOutputTemplates(db) {
// Combine legacy filename_template_X + output_folder_template_X into output_template_X.
// Only sets the new key if it has no user value yet (preserves any existing value).
// The last "/" in the combined template separates folder from filename.
for (const profile of ['bluray', 'dvd']) {
const newKey = `output_template_${profile}`;
const filenameKey = `filename_template_${profile}`;
const folderKey = `output_folder_template_${profile}`;
const existing = await db.get(
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
[newKey]
);
if (existing) {
continue; // already set, don't overwrite
}
const filenameRow = await db.get(
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
[filenameKey]
);
const folderRow = await db.get(
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
[folderKey]
);
const filenameVal = filenameRow ? String(filenameRow.value || '').trim() : '';
const folderVal = folderRow ? String(folderRow.value || '').trim() : '';
if (!filenameVal) {
continue; // nothing to migrate
}
const combined = folderVal ? `${folderVal}/${filenameVal}` : `${filenameVal}/${filenameVal}`;
await db.run(
`INSERT INTO settings_values (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
[newKey, combined]
);
logger.info('migrate:output-template-combined', { profile, combined });
}
}
async function removeDeprecatedSettings(db) {
const deprecatedKeys = [
'pushover_notify_disc_detected',
'pushover_notify_cron_success',
'pushover_notify_cron_error',
'mediainfo_extra_args',
'makemkv_rip_mode',
'makemkv_analyze_extra_args',
'makemkv_rip_extra_args',
'handbrake_preset',
'handbrake_extra_args',
'output_extension',
'filename_template',
'output_folder_template',
'makemkv_backup_mode',
'raw_dir',
'movie_dir',
'raw_dir_other',
'raw_dir_other_owner',
'movie_dir_other',
'movie_dir_other_owner',
'filename_template_bluray',
'filename_template_dvd',
'output_folder_template_bluray',
'output_folder_template_dvd',
'output_extension_audiobook',
'use_plugin_architecture',
'use_plugin_architecture_bluray',
'use_plugin_architecture_bluray_analyze',
'use_plugin_architecture_bluray_rip',
'use_plugin_architecture_bluray_review',
'use_plugin_architecture_bluray_encode',
'use_plugin_architecture_dvd',
'use_plugin_architecture_dvd_analyze',
'use_plugin_architecture_dvd_rip',
'use_plugin_architecture_dvd_review',
'use_plugin_architecture_dvd_encode',
'use_plugin_architecture_cd',
'use_plugin_architecture_cd_analyze',
'use_plugin_architecture_cd_rip',
'use_plugin_architecture_audiobook',
'use_plugin_architecture_audiobook_analyze',
'use_plugin_architecture_audiobook_encode',
'dvd_series_plugin_enabled',
'dvd_series_prescan_enabled'
];
for (const key of deprecatedKeys) {
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]);
if (schemaResult?.changes > 0 || valuesResult?.changes > 0) {
logger.info('migrate:remove-deprecated-setting', { key });
}
}
// Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install
await db.run(
`UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'`
);
}
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
const SETTINGS_SCHEMA_METADATA_UPDATES = [
{
key: 'makemkv_registration_key',
required: 0,
description: 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.',
validation_json: '{}'
},
{
key: 'handbrake_preset_bluray',
required: 0,
description: 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}'
},
{
key: 'handbrake_preset_dvd',
required: 0,
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}'
},
{
key: 'output_template_bluray_series_episode',
required: 1,
description: 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_bluray_series_multi_episode',
required: 1,
description: 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_dvd_series_episode',
required: 1,
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_dvd_series_multi_episode',
required: 1,
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
validation_json: '{"minLength":1}'
}
];
// Settings, die von einer Kategorie in eine andere verschoben werden
const SETTINGS_CATEGORY_MOVES = [
{ key: 'cd_output_template', category: 'Pfade' },
{ key: 'output_template_bluray', category: 'Pfade' },
{ key: 'output_template_bluray_series_episode', category: 'Pfade' },
{ key: 'output_template_bluray_series_multi_episode', category: 'Pfade' },
{ key: 'output_template_dvd', category: 'Pfade' },
{ key: 'output_template_audiobook', category: 'Pfade' },
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
{ key: 'audiobook_raw_template', category: 'Pfade' },
{ key: 'converter_raw_dir', category: 'Pfade' },
{ key: 'converter_movie_dir', category: 'Pfade' },
{ key: 'converter_audio_dir', category: 'Pfade' },
{ key: 'converter_output_template_video', category: 'Pfade' },
{ key: 'converter_output_template_audio', category: 'Pfade' },
{ key: 'server_console_log_output_enabled', category: 'Logging' },
{ key: 'server_console_log_http_enabled', category: 'Logging' },
{ key: 'server_console_log_debug_enabled', category: 'Logging' },
{ key: 'server_console_log_info_enabled', category: 'Logging' },
{ key: 'server_console_log_warn_enabled', category: 'Logging' },
{ key: 'server_console_log_error_enabled', category: 'Logging' }
];
async function migrateSettingsSchemaMetadata(db) {
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
const result = await db.run(
`UPDATE settings_schema
SET required = ?, description = ?, validation_json = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ? AND (required != ? OR description != ? OR validation_json != ?)`,
[
update.required, update.description, update.validation_json,
update.key,
update.required, update.description, update.validation_json
]
);
if (result?.changes > 0) {
logger.info('migrate:settings-schema-metadata', { key: update.key });
}
}
for (const move of SETTINGS_CATEGORY_MOVES) {
const result = await db.run(
`UPDATE settings_schema SET category = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = ? AND category != ?`,
[move.category, move.key, move.category]
);
if (result?.changes > 0) {
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
}
}
const rawDirCdLabel = 'CD RAW-Ordner';
const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
const rawDirCdResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
[rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
);
if (rawDirCdResult?.changes > 0) {
logger.info('migrate:settings-schema-cd-raw-updated', {
key: 'raw_dir_cd',
label: rawDirCdLabel
});
}
const externalStorageDescription = 'Wird links unter "Freie Speicher" angezeigt.';
const externalStorageDescResult = await db.run(
`UPDATE settings_schema
SET description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'external_storage_paths' AND description != ?`,
[externalStorageDescription, externalStorageDescription]
);
if (externalStorageDescResult?.changes > 0) {
logger.info('migrate:settings-schema-external-storage-description-updated', {
key: 'external_storage_paths'
});
}
const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden';
const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).';
const metadataReadyNotifyResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`,
[
metadataReadyNotifyLabel,
metadataReadyNotifyDescription,
metadataReadyNotifyLabel,
metadataReadyNotifyDescription
]
);
if (metadataReadyNotifyResult?.changes > 0) {
logger.info('migrate:settings-schema-metadata-ready-notify-updated', {
key: 'pushover_notify_metadata_ready',
label: metadataReadyNotifyLabel
});
}
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
];
const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', ');
await db.run(
`
UPDATE settings_schema
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'output_template_dvd_series_multi_episode'
AND (
default_value IS NULL
OR TRIM(default_value) = ''
OR default_value IN (${legacyTemplatePlaceholders})
)
`,
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
);
await db.run(
`
INSERT INTO settings_values (key, value, updated_at)
VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO NOTHING
`,
[dvdSeriesMultiEpisodeTemplateDefault]
);
await db.run(
`
UPDATE settings_values
SET value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'output_template_dvd_series_multi_episode'
AND (
value IS NULL
OR TRIM(value) = ''
OR value IN (${legacyTemplatePlaceholders})
)
`,
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
);
// Migrate raw_dir_cd_owner label
await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
);
// depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige
// Settings per INSERT OR IGNORE angelegt werden.
{
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
if (!cols.some((c) => c.name === 'depends_on')) {
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
logger.info('migrate:settings-schema-add-depends_on');
}
}
// Add movie_dir_cd if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
// Add movie_dir_cd_owner if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('mkvmerge_command', 'Tools', 'mkvmerge Kommando', 'string', 1, 'Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt.', 'mkvmerge', '[]', '{"minLength":1}', 2325)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mkvmerge_command', 'mkvmerge')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
// Converter-Pfade und Eigentümer
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_raw_dir', 'Pfade', 'Converter RAW-Ordner', 'path', 0, 'Quellordner für Converter-Eingabedateien. Leer = Standardpfad.', NULL, '[]', '{}', 106)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_movie_dir', 'Pfade', 'Converter Video Output-Ordner', 'path', 0, 'Zielordner für konvertierte Video-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 116)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_audio_dir', 'Pfade', 'Converter Audio Output-Ordner', 'path', 0, 'Zielordner für konvertierte Audio-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 117)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_output_template_video', 'Pfade', 'Converter Video Output-Template', 'string', 0, 'Template für Video-Ausgabedateinamen, z.B. {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 738)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_output_template_audio', 'Pfade', 'Converter Audio Output-Template', 'string', 0, 'Template für Audio-Ausgabedateinamen, z.B. {artist} - {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 739)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_raw_dir_owner', 'Pfade', 'Eigentümer Converter RAW-Ordner', 'string', 0, 'Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1065)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_movie_dir_owner', 'Pfade', 'Eigentümer Converter Video Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1165)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_audio_dir_owner', 'Pfade', 'Eigentümer Converter Audio Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1166)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_devices', '[]')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('auto_eject_after_rip', 'Laufwerk', 'Laufwerk nach Rip auswerfen', 'boolean', 1, 'Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject).', 'false', '[]', '{}', 45)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false')`);
await db.run(`
CREATE TABLE IF NOT EXISTS user_prefs (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await db.run(`
CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'tmdb',
provider_series_id TEXT,
provider_series_name TEXT,
season_number INTEGER,
season_type TEXT NOT NULL DEFAULT 'dvd',
disc_label TEXT,
disc_serial TEXT,
disc_number INTEGER,
language TEXT,
disc_signature TEXT NOT NULL,
fingerprint_json TEXT,
episode_map_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`);
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`);
await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`);
await db.run(`
CREATE TABLE IF NOT EXISTS dvd_series_title_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_job_id INTEGER NOT NULL,
disc_profile_id INTEGER,
title_index INTEGER NOT NULL,
title_kind TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0,
selected INTEGER NOT NULL DEFAULT 1,
provider TEXT NOT NULL DEFAULT 'tmdb',
provider_series_id TEXT,
season_number INTEGER,
episode_start INTEGER,
episode_end INTEGER,
episode_ids_json TEXT,
mapping_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
)
`);
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`);
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`);
await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`);
}
async function backfillJobOutputFolders(db) {
// Remove duplicate (job_id, output_path) rows before unique index is enforced.
await db.run(`
DELETE FROM job_output_folders
WHERE id NOT IN (
SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path
)
`);
// Populate job_output_folders from jobs.output_path for pre-feature jobs.
// Only inserts rows where no entry exists for the job yet.
const rows = await db.all(`
SELECT j.id AS job_id, j.output_path
FROM jobs j
WHERE j.output_path IS NOT NULL
AND j.output_path != ''
AND NOT EXISTS (
SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id
)
`);
if (!Array.isArray(rows) || rows.length === 0) return;
let inserted = 0;
for (const row of rows) {
try {
await db.run(
'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)',
[row.job_id, row.output_path]
);
inserted += 1;
} catch (err) {
logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message });
}
}
if (inserted > 0) {
logger.info('backfill:job-output-folders:done', { inserted });
}
}
async function getDb() {
return initDatabase();
}
module.exports = {
initDatabase,
getDb
};