UI/Features
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
239
backend/src/services/thumbnailService.js
Normal file
239
backend/src/services/thumbnailService.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user