Initial commit mit MkDocs-Dokumentation

This commit is contained in:
2026-03-04 14:18:33 +00:00
parent 6115090da1
commit 31d3e36597
97 changed files with 27518 additions and 1 deletions

View File

@@ -0,0 +1,385 @@
const fs = require('fs');
const { EventEmitter } = require('events');
const { execFile } = require('child_process');
const { promisify } = require('util');
const settingsService = require('./settingsService');
const logger = require('./logger').child('DISK');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
function flattenDevices(nodes, acc = []) {
for (const node of nodes || []) {
acc.push(node);
if (Array.isArray(node.children)) {
flattenDevices(node.children, acc);
}
}
return acc;
}
function buildSignature(info) {
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`;
}
class DiskDetectionService extends EventEmitter {
constructor() {
super();
this.running = false;
this.timer = null;
this.lastDetected = null;
this.lastPresent = false;
this.deviceLocks = new Map();
}
start() {
if (this.running) {
return;
}
this.running = true;
logger.info('start');
this.scheduleNext(1000);
}
stop() {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
logger.info('stop');
}
scheduleNext(delayMs) {
if (!this.running) {
return;
}
this.timer = setTimeout(async () => {
let nextDelay = 4000;
try {
const map = await settingsService.getSettingsMap();
nextDelay = Number(map.disc_poll_interval_ms || 4000);
logger.debug('poll:tick', {
driveMode: map.drive_mode,
driveDevice: map.drive_device,
nextDelay
});
const detected = await this.detectDisc(map);
this.applyDetectionResult(detected, { forceInsertEvent: false });
} catch (error) {
logger.error('poll:error', { error: errorToMeta(error) });
this.emit('error', error);
}
this.scheduleNext(nextDelay);
}, delayMs);
}
async rescanAndEmit() {
try {
const map = await settingsService.getSettingsMap();
logger.info('rescan:requested', {
driveMode: map.drive_mode,
driveDevice: map.drive_device
});
const detected = await this.detectDisc(map);
const result = this.applyDetectionResult(detected, { forceInsertEvent: true });
logger.info('rescan:done', {
present: result.present,
emitted: result.emitted,
changed: result.changed,
detected: result.device || null
});
return result;
} catch (error) {
logger.error('rescan:error', { error: errorToMeta(error) });
throw error;
}
}
normalizeDevicePath(devicePath) {
return String(devicePath || '').trim();
}
lockDevice(devicePath, owner = null) {
const normalized = this.normalizeDevicePath(devicePath);
if (!normalized) {
return null;
}
const entry = this.deviceLocks.get(normalized) || {
count: 0,
owners: []
};
entry.count += 1;
if (owner) {
entry.owners.push(owner);
}
this.deviceLocks.set(normalized, entry);
logger.info('lock:add', {
devicePath: normalized,
count: entry.count,
owner
});
return {
devicePath: normalized,
owner
};
}
unlockDevice(devicePath, owner = null) {
const normalized = this.normalizeDevicePath(devicePath);
if (!normalized) {
return;
}
const entry = this.deviceLocks.get(normalized);
if (!entry) {
return;
}
entry.count = Math.max(0, entry.count - 1);
if (entry.count === 0) {
this.deviceLocks.delete(normalized);
logger.info('lock:remove', {
devicePath: normalized,
owner
});
return;
}
this.deviceLocks.set(normalized, entry);
logger.info('lock:decrement', {
devicePath: normalized,
count: entry.count,
owner
});
}
isDeviceLocked(devicePath) {
const normalized = this.normalizeDevicePath(devicePath);
if (!normalized) {
return false;
}
return this.deviceLocks.has(normalized);
}
getActiveLocks() {
return Array.from(this.deviceLocks.entries()).map(([path, info]) => ({
path,
count: info.count,
owners: info.owners
}));
}
applyDetectionResult(detected, { forceInsertEvent = false } = {}) {
const isPresent = Boolean(detected);
const changed =
isPresent &&
(!this.lastDetected || buildSignature(this.lastDetected) !== buildSignature(detected));
if (isPresent) {
const shouldEmitInserted = forceInsertEvent || !this.lastPresent || changed;
this.lastDetected = detected;
this.lastPresent = true;
if (shouldEmitInserted) {
logger.info('disc:inserted', { detected, forceInsertEvent, changed });
this.emit('discInserted', detected);
return {
present: true,
changed,
emitted: 'discInserted',
device: detected
};
}
return {
present: true,
changed,
emitted: 'none',
device: detected
};
}
if (!isPresent && this.lastPresent) {
const removed = this.lastDetected;
this.lastDetected = null;
this.lastPresent = false;
logger.info('disc:removed', { removed });
this.emit('discRemoved', removed);
return {
present: false,
changed: true,
emitted: 'discRemoved',
device: null
};
}
return {
present: false,
changed: false,
emitted: 'none',
device: null
};
}
async detectDisc(settingsMap) {
if (settingsMap.drive_mode === 'explicit') {
return this.detectExplicit(settingsMap.drive_device);
}
return this.detectAuto();
}
async detectExplicit(devicePath) {
if (this.isDeviceLocked(devicePath)) {
logger.debug('detect:explicit:locked', {
devicePath,
activeLocks: this.getActiveLocks()
});
return null;
}
if (!devicePath || !fs.existsSync(devicePath)) {
logger.debug('detect:explicit:not-found', { devicePath });
return null;
}
const hasMedia = await this.checkMediaPresent(devicePath);
if (!hasMedia) {
logger.debug('detect:explicit:no-media', { devicePath });
return null;
}
const discLabel = await this.getDiscLabel(devicePath);
const details = await this.getBlockDeviceInfo();
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
const detected = {
mode: 'explicit',
path: devicePath,
name: match.name || devicePath.split('/').pop(),
model: match.model || 'Unknown',
label: match.label || null,
discLabel: discLabel || null,
mountpoint: match.mountpoint || null,
fstype: match.fstype || null,
index: this.guessDiscIndex(match.name || devicePath)
};
logger.debug('detect:explicit:success', { detected });
return detected;
}
async detectAuto() {
const details = await this.getBlockDeviceInfo();
const romCandidates = details.filter((entry) => entry.type === 'rom');
for (const item of romCandidates) {
const path = item.path || (item.name ? `/dev/${item.name}` : null);
if (!path) {
continue;
}
if (this.isDeviceLocked(path)) {
logger.debug('detect:auto:skip-locked', {
path,
activeLocks: this.getActiveLocks()
});
continue;
}
const hasMedia = await this.checkMediaPresent(path);
if (!hasMedia) {
continue;
}
const discLabel = await this.getDiscLabel(path);
const detected = {
mode: 'auto',
path,
name: item.name,
model: item.model || 'Optical Drive',
label: item.label || null,
discLabel: discLabel || null,
mountpoint: item.mountpoint || null,
fstype: item.fstype || null,
index: this.guessDiscIndex(item.name)
};
logger.debug('detect:auto:success', { detected });
return detected;
}
logger.debug('detect:auto:none');
return null;
}
async getBlockDeviceInfo() {
try {
const { stdout } = await execFileAsync('lsblk', [
'-J',
'-o',
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
]);
const parsed = JSON.parse(stdout);
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.type,
mountpoint: entry.mountpoint,
fstype: entry.fstype,
label: entry.label,
model: entry.model
}));
logger.debug('lsblk:ok', { deviceCount: devices.length });
return devices;
} catch (error) {
logger.warn('lsblk:failed', { error: errorToMeta(error) });
return [];
}
}
async checkMediaPresent(devicePath) {
try {
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
const has = stdout.trim().length > 0;
logger.debug('blkid:result', { devicePath, hasMedia: has, type: stdout.trim() });
return has;
} catch (error) {
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) });
return false;
}
}
async getDiscLabel(devicePath) {
try {
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'LABEL', devicePath]);
const label = stdout.trim();
logger.debug('blkid:label', { devicePath, discLabel: label || null });
return label || null;
} catch (error) {
logger.debug('blkid:no-label', { devicePath, error: errorToMeta(error) });
return null;
}
}
guessDiscIndex(name) {
if (!name) {
return 0;
}
const match = String(name).match(/(\d+)$/);
return match ? Number(match[1]) : 0;
}
}
module.exports = new DiskDetectionService();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
const path = require('path');
const { logDir: fallbackLogDir } = require('../config');
function normalizeDir(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
}
function getFallbackLogRootDir() {
return path.resolve(fallbackLogDir);
}
function resolveLogRootDir(value) {
return normalizeDir(value) || getFallbackLogRootDir();
}
let runtimeLogRootDir = getFallbackLogRootDir();
function setLogRootDir(value) {
runtimeLogRootDir = resolveLogRootDir(value);
return runtimeLogRootDir;
}
function getLogRootDir() {
return runtimeLogRootDir || getFallbackLogRootDir();
}
function getBackendLogDir() {
return path.join(getLogRootDir(), 'backend');
}
function getJobLogDir() {
return getLogRootDir();
}
module.exports = {
getFallbackLogRootDir,
resolveLogRootDir,
setLogRootDir,
getLogRootDir,
getBackendLogDir,
getJobLogDir
};

View File

@@ -0,0 +1,151 @@
const fs = require('fs');
const path = require('path');
const { logLevel } = require('../config');
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
const LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
function ensureLogDir(logDirPath) {
try {
fs.mkdirSync(logDirPath, { recursive: true });
return true;
} catch (_error) {
return false;
}
}
function resolveWritableBackendLogDir() {
const preferred = getBackendLogDir();
if (ensureLogDir(preferred)) {
return preferred;
}
const fallback = path.join(getFallbackLogRootDir(), 'backend');
if (fallback !== preferred && ensureLogDir(fallback)) {
return fallback;
}
return null;
}
function getDailyFileName() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `backend-${y}-${m}-${day}.log`;
}
function safeJson(value) {
try {
return JSON.stringify(value);
} catch (error) {
return JSON.stringify({ serializationError: error.message });
}
}
function truncateString(value, maxLen = 3000) {
const str = String(value);
if (str.length <= maxLen) {
return str;
}
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
}
function sanitizeMeta(meta) {
if (!meta || typeof meta !== 'object') {
return meta;
}
const out = Array.isArray(meta) ? [] : {};
for (const [key, val] of Object.entries(meta)) {
if (val instanceof Error) {
out[key] = {
name: val.name,
message: val.message,
stack: val.stack
};
continue;
}
if (typeof val === 'string') {
out[key] = truncateString(val, 5000);
continue;
}
out[key] = val;
}
return out;
}
function writeLine(line) {
const backendLogDir = resolveWritableBackendLogDir();
if (!backendLogDir) {
return;
}
const daily = path.join(backendLogDir, getDailyFileName());
const latest = path.join(backendLogDir, 'backend-latest.log');
fs.appendFile(daily, `${line}\n`, (_error) => null);
fs.appendFile(latest, `${line}\n`, (_error) => null);
}
function emit(level, scope, message, meta = null) {
const normLevel = String(level || 'info').toLowerCase();
const lvl = LEVELS[normLevel] || LEVELS.info;
if (lvl < ACTIVE_LEVEL) {
return;
}
const timestamp = new Date().toISOString();
const payload = {
timestamp,
level: normLevel,
scope,
message,
meta: sanitizeMeta(meta)
};
const line = safeJson(payload);
writeLine(line);
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
if (normLevel === 'error') {
console.error(print, payload.meta ? payload.meta : '');
} else if (normLevel === 'warn') {
console.warn(print, payload.meta ? payload.meta : '');
} else {
console.log(print, payload.meta ? payload.meta : '');
}
}
function child(scope) {
return {
debug(message, meta) {
emit('debug', scope, message, meta);
},
info(message, meta) {
emit('info', scope, message, meta);
},
warn(message, meta) {
emit('warn', scope, message, meta);
},
error(message, meta) {
emit('error', scope, message, meta);
}
};
}
module.exports = {
child,
emit
};

View File

@@ -0,0 +1,165 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('PUSHOVER');
const { toBoolean } = require('../utils/validators');
const { errorToMeta } = require('../utils/errorMeta');
const PUSHOVER_API_URL = 'https://api.pushover.net/1/messages.json';
const EVENT_TOGGLE_KEYS = {
metadata_ready: 'pushover_notify_metadata_ready',
rip_started: 'pushover_notify_rip_started',
encoding_started: 'pushover_notify_encoding_started',
job_finished: 'pushover_notify_job_finished',
job_error: 'pushover_notify_job_error',
job_cancelled: 'pushover_notify_job_cancelled',
reencode_started: 'pushover_notify_reencode_started',
reencode_finished: 'pushover_notify_reencode_finished'
};
function truncate(value, maxLen = 1024) {
const text = String(value || '').trim();
if (text.length <= maxLen) {
return text;
}
return `${text.slice(0, maxLen - 20)}...[truncated]`;
}
function normalizePriority(raw) {
const n = Number(raw);
if (Number.isNaN(n)) {
return 0;
}
if (n < -2) {
return -2;
}
if (n > 2) {
return 2;
}
return Math.round(n);
}
class NotificationService {
async notify(eventKey, payload = {}) {
const settings = await settingsService.getSettingsMap();
return this.notifyWithSettings(settings, eventKey, payload);
}
async sendTest({ title, message } = {}) {
return this.notify('test', {
title: title || 'Ripster Test',
message: message || 'PushOver Testnachricht von Ripster.'
});
}
async notifyWithSettings(settings, eventKey, payload = {}) {
const enabled = toBoolean(settings.pushover_enabled);
if (!enabled) {
logger.debug('notify:skip:disabled', { eventKey });
return { sent: false, reason: 'disabled', eventKey };
}
const toggleKey = EVENT_TOGGLE_KEYS[eventKey];
if (toggleKey && !toBoolean(settings[toggleKey])) {
logger.debug('notify:skip:event-disabled', { eventKey, toggleKey });
return { sent: false, reason: 'event-disabled', eventKey };
}
const token = String(settings.pushover_token || '').trim();
const user = String(settings.pushover_user || '').trim();
if (!token || !user) {
logger.warn('notify:skip:missing-credentials', {
eventKey,
hasToken: Boolean(token),
hasUser: Boolean(user)
});
return { sent: false, reason: 'missing-credentials', eventKey };
}
const prefix = String(settings.pushover_title_prefix || 'Ripster').trim();
const title = truncate(payload.title || `${prefix} - ${eventKey}`, 120);
const message = truncate(payload.message || eventKey, 1024);
const priority = normalizePriority(
payload.priority !== undefined ? payload.priority : settings.pushover_priority
);
const timeoutMs = Math.max(1000, Number(settings.pushover_timeout_ms || 7000));
const form = new URLSearchParams();
form.set('token', token);
form.set('user', user);
form.set('title', title);
form.set('message', message);
form.set('priority', String(priority));
const device = String(settings.pushover_device || '').trim();
if (device) {
form.set('device', device);
}
if (payload.url) {
form.set('url', String(payload.url));
}
if (payload.urlTitle) {
form.set('url_title', String(payload.urlTitle));
}
if (payload.sound) {
form.set('sound', String(payload.sound));
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(PUSHOVER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: form.toString(),
signal: controller.signal
});
const rawText = await response.text();
let data = null;
try {
data = rawText ? JSON.parse(rawText) : null;
} catch (error) {
data = null;
}
if (!response.ok) {
const messageText = data?.errors?.join(', ') || data?.error || rawText || `HTTP ${response.status}`;
const error = new Error(`PushOver HTTP ${response.status}: ${messageText}`);
error.statusCode = response.status;
throw error;
}
if (data && data.status !== 1) {
const messageText = data.errors?.join(', ') || data.error || 'Unbekannte PushOver Antwort.';
throw new Error(`PushOver Fehler: ${messageText}`);
}
logger.info('notify:sent', {
eventKey,
title,
priority,
requestId: data?.request || null
});
return {
sent: true,
eventKey,
requestId: data?.request || null
};
} catch (error) {
logger.error('notify:failed', {
eventKey,
title,
error: errorToMeta(error)
});
throw error;
} finally {
clearTimeout(timeout);
}
}
}
module.exports = new NotificationService();

View File

@@ -0,0 +1,92 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
class OmdbService {
async search(query) {
if (!query || query.trim().length === 0) {
return [];
}
logger.info('search:start', { query });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return [];
}
const type = settings.omdb_default_type || 'movie';
const url = new URL('https://www.omdbapi.com/');
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', query.trim());
url.searchParams.set('type', type);
const response = await fetch(url);
if (!response.ok) {
logger.error('search:http-failed', { query, status: response.status });
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
}
const data = await response.json();
if (data.Response === 'False' || !Array.isArray(data.Search)) {
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
return [];
}
const results = data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
logger.info('search:done', { query, count: results.length });
return results;
}
async fetchByImdbId(imdbId) {
const normalizedId = String(imdbId || '').trim().toLowerCase();
if (!/^tt\d{6,12}$/.test(normalizedId)) {
return null;
}
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return null;
}
const url = new URL('https://www.omdbapi.com/');
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const response = await fetch(url);
if (!response.ok) {
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
}
const data = await response.json();
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
}
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
const year = yearMatch ? Number(yearMatch[0]) : null;
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
const result = {
title: data.Title || null,
year: Number.isFinite(year) ? year : null,
imdbId: String(data.imdbID || normalizedId),
type: data.Type || null,
poster,
raw: data
};
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
return result;
}
}
module.exports = new OmdbService();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
const { spawn } = require('child_process');
const logger = require('./logger').child('PROCESS');
const { errorToMeta } = require('../utils/errorMeta');
function streamLines(stream, onLine) {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
const parts = buffer.split(/\r\n|\n|\r/);
buffer = parts.pop() ?? '';
for (const line of parts) {
if (line.length > 0) {
onLine(line);
}
}
});
stream.on('end', () => {
if (buffer.length > 0) {
onLine(buffer);
}
});
}
function spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
onStart,
context = {}
}) {
logger.info('spawn:start', { cmd, args, cwd, context });
const child = spawn(cmd, args, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
if (onStart) {
onStart(child);
}
if (child.stdout && onStdoutLine) {
streamLines(child.stdout, onStdoutLine);
}
if (child.stderr && onStderrLine) {
streamLines(child.stderr, onStderrLine);
}
const promise = new Promise((resolve, reject) => {
child.on('error', (error) => {
logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) });
reject(error);
});
child.on('close', (code, signal) => {
logger.info('spawn:close', { cmd, args, code, signal, context });
if (code === 0) {
resolve({ code, signal });
} else {
const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`);
error.code = code;
error.signal = signal;
reject(error);
}
});
});
const cancel = () => {
if (child.killed) {
return;
}
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
child.kill('SIGINT');
setTimeout(() => {
if (!child.killed) {
logger.warn('spawn:cancel:force-kill', { cmd, args, context, pid: child.pid });
child.kill('SIGKILL');
}
}, 3000);
};
return {
child,
promise,
cancel
};
}
module.exports = {
spawnTrackedProcess
};

View File

@@ -0,0 +1,710 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SETTINGS');
const {
parseJson,
normalizeValueByType,
serializeValueByType,
validateSetting
} = require('../utils/validators');
const { splitArgs } = require('../utils/commandLine');
const { setLogRootDir } = require('./logPathService');
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
const SENSITIVE_SETTING_KEYS = new Set([
'makemkv_registration_key',
'omdb_api_key',
'pushover_token',
'pushover_user'
]);
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
const LOG_DIR_SETTING_KEY = 'log_dir';
function applyRuntimeLogDirSetting(rawValue) {
const resolved = setLogRootDir(rawValue);
try {
fs.mkdirSync(resolved, { recursive: true });
return resolved;
} catch (error) {
const fallbackResolved = setLogRootDir(null);
try {
fs.mkdirSync(fallbackResolved, { recursive: true });
} catch (_fallbackError) {
// ignore fallback fs errors here; logger may still print to console
}
logger.warn('setting:log-dir:fallback', {
configured: String(rawValue || '').trim() || null,
resolved,
fallbackResolved,
error: error?.message || String(error)
});
return fallbackResolved;
}
}
function normalizeTrackIds(rawList) {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set();
const output = [];
for (const item of list) {
const value = Number(item);
if (!Number.isFinite(value) || value <= 0) {
continue;
}
const normalized = String(Math.trunc(value));
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(normalized);
}
return output;
}
function removeSelectionArgs(extraArgs) {
const args = Array.isArray(extraArgs) ? extraArgs : [];
const filtered = [];
for (let i = 0; i < args.length; i += 1) {
const token = String(args[i] || '');
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
if (!skip) {
filtered.push(token);
continue;
}
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
const nextToken = String(args[i + 1] || '');
if (nextToken && !nextToken.startsWith('-')) {
i += 1;
}
}
}
return filtered;
}
function flattenPresetList(input, output = []) {
const list = Array.isArray(input) ? input : [];
for (const entry of list) {
if (!entry || typeof entry !== 'object') {
continue;
}
if (Array.isArray(entry.ChildrenArray) && entry.ChildrenArray.length > 0) {
flattenPresetList(entry.ChildrenArray, output);
continue;
}
output.push(entry);
}
return output;
}
function buildFallbackPresetProfile(presetName, message = null) {
return {
source: 'fallback',
message,
presetName: presetName || null,
audioTrackSelectionBehavior: 'first',
audioLanguages: [],
audioEncoders: [],
audioCopyMask: DEFAULT_AUDIO_COPY_MASK,
audioFallback: 'av_aac',
subtitleTrackSelectionBehavior: 'none',
subtitleLanguages: [],
subtitleBurnBehavior: 'none'
};
}
class SettingsService {
async getSchemaRows() {
const db = await getDb();
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
}
async getSettingsMap() {
const rows = await this.getFlatSettings();
const map = {};
for (const row of rows) {
map[row.key] = row.value;
}
return map;
}
async getFlatSettings() {
const db = await getDb();
const rows = await db.all(
`
SELECT
s.key,
s.category,
s.label,
s.type,
s.required,
s.description,
s.default_value,
s.options_json,
s.validation_json,
s.order_index,
v.value as current_value
FROM settings_schema s
LEFT JOIN settings_values v ON v.key = s.key
ORDER BY s.category ASC, s.order_index ASC
`
);
return rows.map((row) => ({
key: row.key,
category: row.category,
label: row.label,
type: row.type,
required: Boolean(row.required),
description: row.description,
defaultValue: row.default_value,
options: parseJson(row.options_json, []),
validation: parseJson(row.validation_json, {}),
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
orderIndex: row.order_index
}));
}
async getCategorizedSettings() {
const flat = await this.getFlatSettings();
const byCategory = new Map();
for (const item of flat) {
if (!byCategory.has(item.category)) {
byCategory.set(item.category, []);
}
byCategory.get(item.category).push(item);
}
return Array.from(byCategory.entries()).map(([category, settings]) => ({
category,
settings
}));
}
async setSettingValue(key, rawValue) {
const db = await getDb();
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
if (!schema) {
const error = new Error(`Setting ${key} existiert nicht.`);
error.statusCode = 404;
throw error;
}
const result = validateSetting(schema, rawValue);
if (!result.valid) {
const error = new Error(result.errors.join(' '));
error.statusCode = 400;
throw error;
}
const serializedValue = serializeValueByType(schema.type, result.normalized);
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
`,
[key, serializedValue]
);
logger.info('setting:updated', {
key,
value: SENSITIVE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()) ? '[redacted]' : result.normalized
});
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
applyRuntimeLogDirSetting(result.normalized);
}
return {
key,
value: result.normalized
};
}
async setSettingsBulk(rawPatch) {
if (!rawPatch || typeof rawPatch !== 'object' || Array.isArray(rawPatch)) {
const error = new Error('Ungültiger Payload. Erwartet wird ein Objekt mit key/value Paaren.');
error.statusCode = 400;
throw error;
}
const entries = Object.entries(rawPatch);
if (entries.length === 0) {
return [];
}
const db = await getDb();
const schemaRows = await db.all('SELECT * FROM settings_schema');
const schemaByKey = new Map(schemaRows.map((row) => [row.key, row]));
const normalizedEntries = [];
const validationErrors = [];
for (const [key, rawValue] of entries) {
const schema = schemaByKey.get(key);
if (!schema) {
const error = new Error(`Setting ${key} existiert nicht.`);
error.statusCode = 404;
throw error;
}
const result = validateSetting(schema, rawValue);
if (!result.valid) {
validationErrors.push({
key,
message: result.errors.join(' ')
});
continue;
}
normalizedEntries.push({
key,
value: result.normalized,
serializedValue: serializeValueByType(schema.type, result.normalized)
});
}
if (validationErrors.length > 0) {
const error = new Error('Mindestens ein Setting ist ungültig.');
error.statusCode = 400;
error.details = validationErrors;
throw error;
}
try {
await db.exec('BEGIN');
for (const item of normalizedEntries) {
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
`,
[item.key, item.serializedValue]
);
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
const logDirChange = normalizedEntries.find(
(item) => String(item?.key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY
);
if (logDirChange) {
applyRuntimeLogDirSetting(logDirChange.value);
}
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
return normalizedEntries.map((item) => ({
key: item.key,
value: item.value
}));
}
async buildMakeMKVAnalyzeConfig(deviceInfo = null) {
const map = await this.getSettingsMap();
const cmd = map.makemkv_command;
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)];
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
return { cmd, args };
}
async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) {
const map = await this.getSettingsMap();
const cmd = map.makemkv_command;
const sourceArg = `file:${sourcePath}`;
const args = ['-r', 'info', sourceArg];
const titleIdRaw = Number(options?.titleId);
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
logger.debug('cli:makemkv:analyze:path', {
cmd,
args,
sourcePath,
requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null
});
return { cmd, args, sourceArg };
}
async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) {
const map = await this.getSettingsMap();
const cmd = map.makemkv_command;
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
? 'backup'
: 'mkv';
const sourceArg = this.resolveSourceArg(map, deviceInfo);
const rawSelectedTitleId = Number(options?.selectedTitleId);
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
let extra = [];
let baseArgs = [];
if (ripMode === 'backup') {
if (parsedExtra.length > 0) {
logger.warn('cli:makemkv:rip:backup:ignored-extra-args', {
ignored: parsedExtra
});
}
baseArgs = [
'backup',
'--decrypt',
sourceArg,
rawJobDir
];
} else {
extra = parsedExtra;
const minLength = Number(map.makemkv_min_length_minutes || 60);
const hasExplicitTitle = Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0;
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
if (hasExplicitTitle) {
baseArgs = [
'mkv',
sourceArg,
targetTitle,
rawJobDir
];
} else {
baseArgs = [
'--minlength=' + Math.round(minLength * 60),
'mkv',
sourceArg,
targetTitle,
rawJobDir
];
}
}
logger.debug('cli:makemkv:rip', {
cmd,
args: [...baseArgs, ...extra],
ripMode,
rawJobDir,
deviceInfo,
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
? Math.trunc(rawSelectedTitleId)
: null
});
return { cmd, args: [...baseArgs, ...extra] };
}
async buildMakeMKVRegisterConfig() {
const map = await this.getSettingsMap();
const registrationKey = String(map.makemkv_registration_key || '').trim();
if (!registrationKey) {
return null;
}
const cmd = map.makemkv_command || 'makemkvcon';
const args = ['reg', registrationKey];
logger.debug('cli:makemkv:register', { cmd, args: ['reg', '<redacted>'] });
return {
cmd,
args,
argsForLog: ['reg', '<redacted>']
};
}
async buildMediaInfoConfig(inputPath) {
const map = await this.getSettingsMap();
const cmd = map.mediainfo_command || 'mediainfo';
const baseArgs = ['--Output=JSON'];
const extra = splitArgs(map.mediainfo_extra_args);
const args = [...baseArgs, ...extra, inputPath];
logger.debug('cli:mediainfo', { cmd, args, inputPath });
return { cmd, args };
}
async buildHandBrakeConfig(inputFile, outputFile, options = {}) {
const map = await this.getSettingsMap();
const cmd = map.handbrake_command;
const rawTitleId = Number(options?.titleId);
const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
? Math.trunc(rawTitleId)
: null;
const baseArgs = ['-i', inputFile, '-o', outputFile];
if (selectedTitleId !== null) {
baseArgs.push('-t', String(selectedTitleId));
}
baseArgs.push('-Z', map.handbrake_preset);
const extra = splitArgs(map.handbrake_extra_args);
const rawSelection = options?.trackSelection || null;
const hasSelection = rawSelection && typeof rawSelection === 'object';
if (!hasSelection) {
logger.debug('cli:handbrake', {
cmd,
args: [...baseArgs, ...extra],
inputFile,
outputFile,
selectedTitleId
});
return { cmd, args: [...baseArgs, ...extra] };
}
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds);
const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
const filteredExtra = removeSelectionArgs(extra);
const overrideArgs = [
'-a',
audioTrackIds.length > 0 ? audioTrackIds.join(',') : 'none',
'-s',
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
];
if (subtitleBurnTrackId !== null) {
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
}
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
if (subtitleForcedTrackId !== null) {
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
} else if (subtitleForcedOnly) {
overrideArgs.push('--subtitle-forced');
}
const args = [...baseArgs, ...filteredExtra, ...overrideArgs];
logger.debug('cli:handbrake:with-selection', {
cmd,
args,
inputFile,
outputFile,
selectedTitleId,
trackSelection: {
audioTrackIds,
subtitleTrackIds,
subtitleBurnTrackId,
subtitleDefaultTrackId,
subtitleForcedTrackId,
subtitleForcedOnly
}
});
return {
cmd,
args,
trackSelection: {
audioTrackIds,
subtitleTrackIds,
subtitleBurnTrackId,
subtitleDefaultTrackId,
subtitleForcedTrackId,
subtitleForcedOnly
}
};
}
resolveHandBrakeSourceArg(map, deviceInfo = null) {
if (map.drive_mode === 'explicit') {
const device = String(map.drive_device || '').trim();
if (!device) {
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
}
return device;
}
const detectedPath = String(deviceInfo?.path || '').trim();
if (detectedPath) {
return detectedPath;
}
const configuredPath = String(map.drive_device || '').trim();
if (configuredPath) {
return configuredPath;
}
return '/dev/sr0';
}
async buildHandBrakeScanConfig(deviceInfo = null) {
const map = await this.getSettingsMap();
const cmd = map.handbrake_command || 'HandBrakeCLI';
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
const args = ['--scan', '--json', '-i', sourceArg, '-t', '0'];
logger.debug('cli:handbrake:scan', {
cmd,
args,
deviceInfo
});
return { cmd, args, sourceArg };
}
async buildHandBrakeScanConfigForInput(inputPath, options = {}) {
const map = await this.getSettingsMap();
const cmd = map.handbrake_command || 'HandBrakeCLI';
// RAW backup folders must be scanned as full BD source to get usable title list.
const rawTitleId = Number(options?.titleId);
const titleId = Number.isFinite(rawTitleId) && rawTitleId > 0
? Math.trunc(rawTitleId)
: 0;
const args = ['--scan', '--json', '-i', inputPath, '-t', String(titleId)];
logger.debug('cli:handbrake:scan:input', {
cmd,
args,
inputPath,
titleId: titleId > 0 ? titleId : null
});
return { cmd, args, sourceArg: inputPath };
}
async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) {
const map = await this.getSettingsMap();
const cmd = map.handbrake_command || 'HandBrakeCLI';
const presetName = map.handbrake_preset || null;
const rawTitleId = Number(options?.titleId);
const presetScanTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
? Math.trunc(rawTitleId)
: 1;
if (!presetName) {
return buildFallbackPresetProfile(null, 'Kein HandBrake-Preset konfiguriert.');
}
if (!sampleInputPath || !fs.existsSync(sampleInputPath)) {
return buildFallbackPresetProfile(
presetName,
'Preset-Export übersprungen: kein gültiger Sample-Input für HandBrake-Scan.'
);
}
const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const exportFile = path.join(os.tmpdir(), `${exportName}.json`);
const args = [
'--scan',
'-i',
sampleInputPath,
'-t',
String(presetScanTitleId),
'-Z',
presetName,
'--preset-export',
exportName,
'--preset-export-file',
exportFile
];
try {
const result = spawnSync(cmd, args, {
encoding: 'utf-8',
timeout: 180000,
maxBuffer: 10 * 1024 * 1024
});
if (result.error) {
return buildFallbackPresetProfile(
presetName,
`Preset-Export fehlgeschlagen: ${result.error.message}`
);
}
if (result.status !== 0) {
const stderr = String(result.stderr || '').trim();
const stdout = String(result.stdout || '').trim();
const tail = stderr || stdout || `exit=${result.status}`;
return buildFallbackPresetProfile(
presetName,
`Preset-Export fehlgeschlagen (${tail.slice(0, 280)})`
);
}
if (!fs.existsSync(exportFile)) {
return buildFallbackPresetProfile(
presetName,
'Preset-Export fehlgeschlagen: Exportdatei wurde nicht erzeugt.'
);
}
const raw = fs.readFileSync(exportFile, 'utf-8');
const parsed = JSON.parse(raw);
const presetEntries = flattenPresetList(parsed?.PresetList || []);
const exported = presetEntries.find((entry) => entry.PresetName === exportName) || presetEntries[0];
if (!exported) {
return buildFallbackPresetProfile(
presetName,
'Preset-Export fehlgeschlagen: Kein Preset in Exportdatei gefunden.'
);
}
return {
source: 'preset-export',
message: null,
presetName,
audioTrackSelectionBehavior: exported.AudioTrackSelectionBehavior || 'first',
audioLanguages: Array.isArray(exported.AudioLanguageList) ? exported.AudioLanguageList : [],
audioEncoders: Array.isArray(exported.AudioList)
? exported.AudioList
.map((item) => item?.AudioEncoder)
.filter(Boolean)
: [],
audioCopyMask: Array.isArray(exported.AudioCopyMask)
? exported.AudioCopyMask
: DEFAULT_AUDIO_COPY_MASK,
audioFallback: exported.AudioEncoderFallback || 'av_aac',
subtitleTrackSelectionBehavior: exported.SubtitleTrackSelectionBehavior || 'none',
subtitleLanguages: Array.isArray(exported.SubtitleLanguageList) ? exported.SubtitleLanguageList : [],
subtitleBurnBehavior: exported.SubtitleBurnBehavior || 'none'
};
} catch (error) {
return buildFallbackPresetProfile(
presetName,
`Preset-Export Ausnahme: ${error.message}`
);
} finally {
try {
if (fs.existsSync(exportFile)) {
fs.unlinkSync(exportFile);
}
} catch (_error) {
// ignore cleanup errors
}
}
}
resolveSourceArg(map, deviceInfo = null) {
const mode = map.drive_mode;
if (mode === 'explicit') {
const device = map.drive_device;
if (!device) {
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
}
return `dev:${device}`;
}
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
return `disc:${deviceInfo.index}`;
}
return `disc:${map.makemkv_source_index ?? 0}`;
}
}
module.exports = new SettingsService();

View File

@@ -0,0 +1,65 @@
const { WebSocketServer } = require('ws');
const logger = require('./logger').child('WS');
class WebSocketService {
constructor() {
this.wss = null;
this.clients = new Set();
}
init(httpServer) {
if (this.wss) {
return;
}
this.wss = new WebSocketServer({ server: httpServer, path: '/ws' });
this.wss.on('connection', (socket) => {
this.clients.add(socket);
logger.info('client:connected', { clients: this.clients.size });
socket.send(
JSON.stringify({
type: 'WS_CONNECTED',
payload: { connectedAt: new Date().toISOString() }
})
);
socket.on('close', () => {
this.clients.delete(socket);
logger.info('client:closed', { clients: this.clients.size });
});
socket.on('error', () => {
this.clients.delete(socket);
logger.warn('client:error', { clients: this.clients.size });
});
});
}
broadcast(type, payload) {
if (!this.wss) {
return;
}
logger.debug('broadcast', {
type,
clients: this.clients.size,
payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : []
});
const message = JSON.stringify({
type,
payload,
timestamp: new Date().toISOString()
});
for (const client of this.clients) {
if (client.readyState === client.OPEN) {
client.send(message);
}
}
}
}
module.exports = new WebSocketService();