UI/Features

This commit is contained in:
2026-03-13 11:07:34 +00:00
parent 7948dd298c
commit 5b41f728c5
28 changed files with 5690 additions and 936 deletions

View File

@@ -3,3 +3,9 @@ DB_PATH=./data/ripster.db
CORS_ORIGIN=http://localhost:5173
LOG_DIR=./logs
LOG_LEVEL=debug
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
DEFAULT_RAW_DIR=
DEFAULT_MOVIE_DIR=
DEFAULT_CD_DIR=

View File

@@ -3,11 +3,25 @@ const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath);
const dataDir = path.dirname(resolvedDbPath);
function resolveOutputPath(envValue, ...subParts) {
const raw = String(envValue || '').trim();
if (raw) {
return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw);
}
return path.join(dataDir, ...subParts);
}
module.exports = {
port: process.env.PORT ? Number(process.env.PORT) : 3001,
dbPath: path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath),
dbPath: resolvedDbPath,
dataDir,
corsOrigin: process.env.CORS_ORIGIN || '*',
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
logLevel: process.env.LOG_LEVEL || 'info'
logLevel: process.env.LOG_LEVEL || 'info',
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd')
};

View File

@@ -38,25 +38,11 @@ const LEGACY_PROFILE_SETTING_MIGRATIONS = [
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
},
{
legacyKey: 'filename_template',
profileKeys: ['filename_template_bluray', 'filename_template_dvd']
},
{
legacyKey: 'output_folder_template',
profileKeys: ['output_folder_template_bluray', 'output_folder_template_dvd']
legacyKey: 'output_template',
profileKeys: ['output_template_bluray', 'output_template_dvd']
}
];
const INSTALL_PATH_SETTING_DEFAULTS = [
{
key: 'raw_dir',
pathParts: ['output', 'raw'],
legacyDefaults: ['data/output/raw', './data/output/raw']
},
{
key: 'movie_dir',
pathParts: ['output', 'movies'],
legacyDefaults: ['data/output/movies', './data/output/movies']
},
{
key: 'log_dir',
pathParts: ['logs'],
@@ -540,6 +526,7 @@ async function openAndPrepareDatabase() {
await seedFromSchemaFile(dbInstance);
await syncInstallPathSettingDefaults(dbInstance);
await migrateLegacyProfiledToolSettings(dbInstance);
await migrateOutputTemplates(dbInstance);
await removeDeprecatedSettings(dbInstance);
await migrateSettingsSchemaMetadata(dbInstance);
await ensurePipelineStateRow(dbInstance);
@@ -736,6 +723,49 @@ async function ensurePipelineStateRow(db) {
);
}
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',
@@ -748,14 +778,31 @@ async function removeDeprecatedSettings(db) {
'output_extension',
'filename_template',
'output_folder_template',
'makemkv_backup_mode'
'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'
];
for (const key of deprecatedKeys) {
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
if (result?.changes > 0) {
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)
@@ -775,6 +822,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
}
];
// 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_dvd', category: 'Pfade' }
];
async function migrateSettingsSchemaMetadata(db) {
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
const result = await db.run(
@@ -791,6 +845,16 @@ async function migrateSettingsSchemaMetadata(db) {
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 });
}
}
}
async function getDb() {

View File

@@ -19,6 +19,7 @@ const diskDetectionService = require('./services/diskDetectionService');
const hardwareMonitorService = require('./services/hardwareMonitorService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
async function start() {
logger.info('backend:start:init');
@@ -40,6 +41,7 @@ async function start() {
app.use('/api/history', historyRoutes);
app.use('/api/crons', cronRoutes);
app.use('/api/runtime', runtimeRoutes);
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
app.use(errorHandler);
@@ -72,6 +74,8 @@ async function start() {
server.listen(port, () => {
logger.info('backend:listening', { port });
// Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start)
migrateExistingThumbnails().catch(() => {});
});
const shutdown = () => {

View File

@@ -112,19 +112,38 @@ router.post(
})
);
router.get(
'/:id/delete-preview',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
logger.info('get:delete-preview', {
reqId: req.reqId,
id,
includeRelated
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
res.json({ preview });
})
);
router.post(
'/:id/delete',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
logger.warn('post:delete-job', {
reqId: req.reqId,
id,
target
target,
includeRelated
});
const result = await historyService.deleteJob(id, target);
const result = await historyService.deleteJob(id, target, { includeRelated });
const uiReset = await pipelineService.resetFrontendState('history_delete');
res.json({ ...result, uiReset });
})

View File

@@ -99,8 +99,28 @@ router.post(
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const ripConfig = req.body || {};
logger.info('post:cd:start', { reqId: req.reqId, jobId, format: ripConfig.format });
const result = await pipelineService.startCdRip(jobId, ripConfig);
logger.info('post:cd:start', {
reqId: req.reqId,
jobId,
format: ripConfig.format,
selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds)
? ripConfig.selectedPreEncodeScriptIds.length
: 0,
selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds)
? ripConfig.selectedPostEncodeScriptIds.length
: 0,
selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds)
? ripConfig.selectedPreEncodeChainIds.length
: 0,
selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds)
? ripConfig.selectedPostEncodeChainIds.length
: 0
});
const result = await pipelineService.enqueueOrStartCdAction(
jobId,
ripConfig,
() => pipelineService.startCdRip(jobId, ripConfig)
);
res.json({ result });
})
);

View File

@@ -29,6 +29,15 @@ router.get(
})
);
router.get(
'/effective-paths',
asyncHandler(async (req, res) => {
logger.debug('get:settings:effective-paths', { reqId: req.reqId });
const paths = await settingsService.getEffectivePaths();
res.json(paths);
})
);
router.get(
'/handbrake-presets',
asyncHandler(async (req, res) => {

View File

@@ -431,6 +431,16 @@ async function ripAndEncode(options) {
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
onProgress && onProgress({
phase: 'rip',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: (i / tracksToRip.length) * 50
});
log('info', `Rippe Track ${track.position} von ${tracksToRip.length}`);
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
@@ -445,9 +455,11 @@ async function ripAndEncode(options) {
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
onProgress && onProgress({
phase: 'rip',
trackEvent: 'progress',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: parsed.percent,
percent: overallPercent
});
}
@@ -467,9 +479,11 @@ async function ripAndEncode(options) {
onProgress && onProgress({
phase: 'rip',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: ((i + 1) / tracksToRip.length) * 50
});
@@ -484,14 +498,25 @@ async function ripAndEncode(options) {
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
onProgress && onProgress({
phase: 'encode',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: 50 + ((i / tracksToRip.length) * 50)
});
ensureDir(path.dirname(outFile));
log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
fs.renameSync(wavFile, outFile);
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: 50 + ((i + 1) / tracksToRip.length) * 50
});
log('info', `WAV für Track ${track.position} gespeichert.`);
@@ -511,6 +536,16 @@ async function ripAndEncode(options) {
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
ensureDir(path.dirname(outFile));
onProgress && onProgress({
phase: 'encode',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: 50 + ((i / tracksToRip.length) * 50)
});
log('info', `Encodiere Track ${track.position}${outFilename}`);
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
@@ -536,18 +571,13 @@ async function ripAndEncode(options) {
);
}
// Clean up WAV after encode
try {
fs.unlinkSync(wavFile);
} catch (_error) {
// ignore cleanup errors
}
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: 50 + ((i + 1) / tracksToRip.length) * 50
});

View File

@@ -8,6 +8,38 @@ const { parseToc } = require('./cdRipService');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
const DEFAULT_POLL_INTERVAL_MS = 4000;
const MIN_POLL_INTERVAL_MS = 1000;
const MAX_POLL_INTERVAL_MS = 60000;
function toBoolean(value, fallback = false) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
return fallback;
}
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false;
}
return fallback;
}
function clampPollIntervalMs(rawValue) {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) {
return DEFAULT_POLL_INTERVAL_MS;
}
const clamped = Math.max(MIN_POLL_INTERVAL_MS, Math.min(MAX_POLL_INTERVAL_MS, Math.trunc(parsed)));
return clamped || DEFAULT_POLL_INTERVAL_MS;
}
function flattenDevices(nodes, acc = []) {
for (const node of nodes || []) {
@@ -56,9 +88,6 @@ function normalizeMediaProfile(rawValue) {
if (value === 'cd' || value === 'audio_cd') {
return 'cd';
}
if (value === 'disc' || value === 'other' || value === 'sonstiges') {
return 'other';
}
return null;
}
@@ -188,18 +217,24 @@ class DiskDetectionService extends EventEmitter {
}
this.timer = setTimeout(async () => {
let nextDelay = 4000;
let nextDelay = DEFAULT_POLL_INTERVAL_MS;
try {
const map = await settingsService.getSettingsMap();
nextDelay = Number(map.disc_poll_interval_ms || 4000);
nextDelay = clampPollIntervalMs(map.disc_poll_interval_ms);
const autoDetectionEnabled = toBoolean(map.disc_auto_detection_enabled, true);
logger.debug('poll:tick', {
driveMode: map.drive_mode,
driveDevice: map.drive_device,
nextDelay
nextDelay,
autoDetectionEnabled
});
const detected = await this.detectDisc(map);
this.applyDetectionResult(detected, { forceInsertEvent: false });
if (autoDetectionEnabled) {
const detected = await this.detectDisc(map);
this.applyDetectionResult(detected, { forceInsertEvent: false });
} else {
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
}
} catch (error) {
logger.error('poll:error', { error: errorToMeta(error) });
this.emit('error', error);

View File

@@ -20,14 +20,14 @@ const RELEVANT_SETTINGS_KEYS = new Set([
'hardware_monitoring_enabled',
'hardware_monitoring_interval_ms',
'raw_dir',
'raw_dir_bluray',
'raw_dir_dvd',
'raw_dir_cd',
'movie_dir',
'movie_dir_bluray',
'movie_dir_dvd',
'log_dir'
]);
const MONITORED_PATH_DEFINITIONS = [
{ key: 'raw_dir', label: 'RAW-Verzeichnis' },
{ key: 'movie_dir', label: 'Movie-Verzeichnis' },
{ key: 'log_dir', label: 'Log-Verzeichnis' }
];
function nowIso() {
return new Date().toISOString();
@@ -53,6 +53,10 @@ function toBoolean(value) {
return Boolean(normalized);
}
function normalizePathSetting(value) {
return String(value || '').trim();
}
function clampIntervalMs(rawValue) {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) {
@@ -392,10 +396,43 @@ class HardwareMonitorService {
}
buildMonitoredPaths(settingsMap = {}) {
return MONITORED_PATH_DEFINITIONS.map((definition) => ({
...definition,
path: String(settingsMap?.[definition.key] || '').trim()
}));
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray');
const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd');
const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd');
const blurayRawPath = normalizePathSetting(bluray?.raw_dir);
const dvdRawPath = normalizePathSetting(dvd?.raw_dir);
const cdRawPath = normalizePathSetting(cd?.raw_dir);
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
const monitoredPaths = [];
const addPath = (key, label, monitoredPath) => {
monitoredPaths.push({
key,
label,
path: normalizePathSetting(monitoredPath)
});
};
if (blurayRawPath && dvdRawPath && blurayRawPath !== dvdRawPath) {
addPath('raw_dir_bluray', 'RAW-Verzeichnis (Blu-ray)', blurayRawPath);
addPath('raw_dir_dvd', 'RAW-Verzeichnis (DVD)', dvdRawPath);
} else {
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
}
addPath('raw_dir_cd', 'CD-Verzeichnis', cdRawPath || sourceMap.raw_dir_cd);
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
addPath('movie_dir_dvd', 'Movie-Verzeichnis (DVD)', dvdMoviePath);
} else {
addPath('movie_dir', 'Movie-Verzeichnis', blurayMoviePath || dvdMoviePath || sourceMap.movie_dir);
}
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
return monitoredPaths;
}
pathsSignature(paths = []) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,38 @@ const path = require('path');
const { spawn } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPTS');
const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService');
const { errorToMeta } = require('../utils/errorMeta');
const SCRIPT_NAME_MAX_LENGTH = 120;
const SCRIPT_BODY_MAX_LENGTH = 200000;
const SCRIPT_TEST_TIMEOUT_MS = 120000;
const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms';
const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0;
const SCRIPT_TEST_TIMEOUT_MS = (() => {
const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.trunc(parsed));
}
return DEFAULT_SCRIPT_TEST_TIMEOUT_MS;
})();
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) {
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.trunc(parsed));
}
if (fallbackMs === null || fallbackMs === undefined) {
return null;
}
const parsedFallback = Number(fallbackMs);
if (Number.isFinite(parsedFallback)) {
return Math.max(0, Math.trunc(parsedFallback));
}
return 0;
}
function normalizeScriptId(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value <= 0) {
@@ -184,6 +208,7 @@ function killChildProcessTree(child, signal = 'SIGTERM') {
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
return new Promise((resolve, reject) => {
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
const startedAt = Date.now();
let ended = false;
const child = spawn(cmd, args, {
@@ -206,15 +231,18 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
let stderrTruncated = false;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
killChildProcessTree(child, 'SIGTERM');
setTimeout(() => {
if (!ended) {
killChildProcessTree(child, 'SIGKILL');
}
}, 2000);
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
let timeout = null;
if (effectiveTimeoutMs > 0) {
timeout = setTimeout(() => {
timedOut = true;
killChildProcessTree(child, 'SIGTERM');
setTimeout(() => {
if (!ended) {
killChildProcessTree(child, 'SIGKILL');
}
}, 2000);
}, effectiveTimeoutMs);
}
const onData = (streamName, chunk) => {
if (streamName === 'stdout') {
@@ -233,13 +261,17 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
child.on('error', (error) => {
ended = true;
clearTimeout(timeout);
if (timeout) {
clearTimeout(timeout);
}
reject(error);
});
child.on('close', (code, signal) => {
ended = true;
clearTimeout(timeout);
if (timeout) {
clearTimeout(timeout);
}
const endedAt = Date.now();
resolve({
code: Number.isFinite(Number(code)) ? Number(code) : null,
@@ -255,6 +287,23 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
});
}
async function resolveScriptTestTimeoutMs(options = {}) {
const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null);
if (timeoutFromOptions !== null) {
return timeoutFromOptions;
}
try {
const settingsMap = await settingsService.getSettingsMap();
return normalizeScriptTestTimeoutMs(
settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY],
SCRIPT_TEST_TIMEOUT_MS
);
} catch (error) {
logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) });
return SCRIPT_TEST_TIMEOUT_MS;
}
}
class ScriptService {
async listScripts() {
const db = await getDb();
@@ -506,8 +555,7 @@ class ScriptService {
async testScript(scriptId, options = {}) {
const script = await this.getScriptById(scriptId);
const timeoutMs = Number(options?.timeoutMs);
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS;
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
const prepared = await this.createExecutableScriptFile(script, {
source: 'settings_test',
mode: 'test'

View File

@@ -13,6 +13,8 @@ const {
const { splitArgs } = require('../utils/commandLine');
const { setLogRootDir } = require('./logPathService');
const { defaultRawDir: DEFAULT_RAW_DIR, defaultMovieDir: DEFAULT_MOVIE_DIR, defaultCdDir: DEFAULT_CD_DIR } = require('../config');
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
const SETTINGS_CACHE_TTL_MS = 15000;
@@ -36,29 +38,25 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
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';
const MEDIA_PROFILES = ['bluray', 'dvd', 'other', 'cd'];
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd'];
const PROFILED_SETTINGS = {
raw_dir: {
bluray: 'raw_dir_bluray',
dvd: 'raw_dir_dvd',
other: 'raw_dir_other',
cd: 'raw_dir_cd'
},
raw_dir_owner: {
bluray: 'raw_dir_bluray_owner',
dvd: 'raw_dir_dvd_owner',
other: 'raw_dir_other_owner',
cd: 'raw_dir_cd_owner'
},
movie_dir: {
bluray: 'movie_dir_bluray',
dvd: 'movie_dir_dvd',
other: 'movie_dir_other'
dvd: 'movie_dir_dvd'
},
movie_dir_owner: {
bluray: 'movie_dir_bluray_owner',
dvd: 'movie_dir_dvd_owner',
other: 'movie_dir_other_owner'
dvd: 'movie_dir_dvd_owner'
},
mediainfo_extra_args: {
bluray: 'mediainfo_extra_args_bluray',
@@ -88,13 +86,9 @@ const PROFILED_SETTINGS = {
bluray: 'output_extension_bluray',
dvd: 'output_extension_dvd'
},
filename_template: {
bluray: 'filename_template_bluray',
dvd: 'filename_template_dvd'
},
output_folder_template: {
bluray: 'output_folder_template_bluray',
dvd: 'output_folder_template_dvd'
output_template: {
bluray: 'output_template_bluray',
dvd: 'output_template_dvd'
}
};
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
@@ -373,8 +367,8 @@ function normalizeMediaProfileValue(value) {
) {
return 'dvd';
}
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
return 'other';
if (raw === 'cd' || raw === 'audio_cd') {
return 'cd';
}
return null;
}
@@ -387,9 +381,6 @@ function resolveProfileFallbackOrder(profile) {
if (normalized === 'dvd') {
return ['dvd', 'bluray'];
}
if (normalized === 'other') {
return ['dvd', 'bluray'];
}
return ['dvd', 'bluray'];
}
@@ -694,6 +685,14 @@ class SettingsService {
if (hasUsableProfileSpecificValue(selectedProfileValue)) {
resolvedValue = selectedProfileValue;
}
// Fallback to hardcoded install defaults when no setting value is configured
if (!hasUsableProfileSpecificValue(resolvedValue)) {
if (legacyKey === 'raw_dir') {
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
} else if (legacyKey === 'movie_dir') {
resolvedValue = DEFAULT_MOVIE_DIR;
}
}
effective[legacyKey] = resolvedValue;
continue;
}
@@ -718,6 +717,23 @@ class SettingsService {
return this.resolveEffectiveToolSettings(map, mediaProfile);
}
async getEffectivePaths() {
const map = await this.getSettingsMap();
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
const cd = this.resolveEffectiveToolSettings(map, 'cd');
return {
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
cd: { raw: cd.raw_dir },
defaults: {
raw: DEFAULT_RAW_DIR,
movies: DEFAULT_MOVIE_DIR,
cd: DEFAULT_CD_DIR
}
};
}
async fetchFlatSettingsFromDb() {
const db = await getDb();
const rows = await db.all(
@@ -1458,4 +1474,8 @@ class SettingsService {
}
}
module.exports = new SettingsService();
const settingsServiceInstance = new SettingsService();
settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR;
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR;
module.exports = settingsServiceInstance;

View File

@@ -0,0 +1,239 @@
'use strict';
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { dataDir } = require('../config');
const { getDb } = require('../db/database');
const logger = require('./logger').child('THUMBNAIL');
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
const MAX_REDIRECTS = 5;
function ensureDirs() {
fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
}
function cacheFilePath(jobId) {
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
}
function persistentFilePath(jobId) {
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
}
function localUrl(jobId) {
return `/api/thumbnails/job-${jobId}.jpg`;
}
function isLocalUrl(url) {
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
}
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
if (redirectsLeft <= 0) {
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
}
const proto = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(destPath);
const cleanup = () => {
try { file.destroy(); } catch (_) {}
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
};
proto.get(url, { timeout: 15000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
file.close(() => {
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
});
return;
}
if (res.statusCode !== 200) {
res.resume();
cleanup();
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
}
res.pipe(file);
file.on('finish', () => file.close(() => resolve()));
file.on('error', (err) => { cleanup(); reject(err); });
}).on('error', (err) => {
cleanup();
reject(err);
}).on('timeout', function () {
this.destroy();
cleanup();
reject(new Error('Timeout beim Bild-Download'));
});
});
}
/**
* Lädt das Bild einer extern-URL in den Cache herunter.
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
* @returns {Promise<string|null>} lokaler Pfad oder null
*/
async function cacheJobThumbnail(jobId, posterUrl) {
if (!posterUrl || isLocalUrl(posterUrl)) return null;
try {
ensureDirs();
const dest = cacheFilePath(jobId);
await downloadImage(posterUrl, dest);
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
return dest;
} catch (err) {
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
return null;
}
}
/**
* Verschiebt das gecachte Bild in den persistenten Ordner.
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
* Wird nach erfolgreichem Rip aufgerufen.
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
*/
function promoteJobThumbnail(jobId) {
try {
ensureDirs();
const src = cacheFilePath(jobId);
const dest = persistentFilePath(jobId);
if (fs.existsSync(src)) {
fs.renameSync(src, dest);
logger.info('thumbnail:promoted', { jobId, dest });
return localUrl(jobId);
}
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
if (fs.existsSync(dest)) {
return localUrl(jobId);
}
logger.warn('thumbnail:promote:no-source', { jobId });
return null;
} catch (err) {
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
return null;
}
}
/**
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
*/
function getThumbnailsDir() {
return THUMBNAILS_DIR;
}
/**
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
* und nicht auf die Datei des alten Jobs angewiesen ist.
* @returns {string|null} neue lokale URL oder null
*/
function copyThumbnail(sourceJobId, targetJobId) {
try {
const src = persistentFilePath(sourceJobId);
if (!fs.existsSync(src)) return null;
ensureDirs();
const dest = persistentFilePath(targetJobId);
fs.copyFileSync(src, dest);
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
return localUrl(targetJobId);
} catch (err) {
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
return null;
}
}
/**
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
* Wird beim Löschen eines Jobs aufgerufen.
*/
function deleteThumbnail(jobId) {
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (err) {
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
}
}
}
/**
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
* mit kurzem Delay um externe Server nicht zu überlasten.
*/
async function migrateExistingThumbnails() {
try {
ensureDirs();
const db = await getDb();
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
const jobs = await db.all(
`SELECT id, poster_url FROM jobs
WHERE rip_successful = 1
AND poster_url IS NOT NULL
AND poster_url != ''
AND poster_url NOT LIKE '/api/thumbnails/%'
ORDER BY id ASC`
);
if (!jobs.length) {
logger.info('thumbnail:migrate:nothing-to-do');
return;
}
logger.info('thumbnail:migrate:start', { count: jobs.length });
let succeeded = 0;
let failed = 0;
for (const job of jobs) {
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
const dest = persistentFilePath(job.id);
if (fs.existsSync(dest)) {
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
succeeded++;
continue;
}
try {
await downloadImage(job.poster_url, dest);
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
logger.info('thumbnail:migrate:ok', { jobId: job.id });
succeeded++;
} catch (err) {
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
failed++;
}
// Kurze Pause zwischen Downloads (externe Server schonen)
await new Promise((r) => setTimeout(r, 300));
}
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
} catch (err) {
logger.error('thumbnail:migrate:error', { error: err.message });
}
}
module.exports = {
cacheJobThumbnail,
promoteJobThumbnail,
copyThumbnail,
deleteThumbnail,
getThumbnailsDir,
migrateExistingThumbnails,
isLocalUrl
};