From 5b41f728c5f40389e9721b02cdcf942e4c1713a1 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Fri, 13 Mar 2026 11:07:34 +0000 Subject: [PATCH] UI/Features --- backend/.env.example | 6 + backend/src/config.js | 18 +- backend/src/db/database.js | 102 +- backend/src/index.js | 4 + backend/src/routes/historyRoutes.js | 23 +- backend/src/routes/pipelineRoutes.js | 24 +- backend/src/routes/settingsRoutes.js | 9 + backend/src/services/cdRipService.js | 44 +- backend/src/services/diskDetectionService.js | 51 +- .../src/services/hardwareMonitorService.js | 55 +- backend/src/services/historyService.js | 1062 ++++++++++++- backend/src/services/pipelineService.js | 1402 +++++++++++++++-- backend/src/services/scriptService.js | 76 +- backend/src/services/settingsService.js | 60 +- backend/src/services/thumbnailService.js | 239 +++ db/schema.sql | 118 +- frontend/src/App.jsx | 24 +- frontend/src/api/client.js | 17 +- frontend/src/components/CdRipConfigPanel.jsx | 1029 +++++++++--- .../src/components/DynamicSettingsForm.jsx | 715 +++++++-- frontend/src/components/JobDetailDialog.jsx | 392 +++-- .../src/components/PipelineStatusCard.jsx | 48 +- frontend/src/pages/DashboardPage.jsx | 223 ++- frontend/src/pages/HistoryPage.jsx | 489 +++++- frontend/src/pages/SettingsPage.jsx | 79 +- frontend/src/styles/app.css | 305 +++- frontend/src/utils/statusPresentation.js | 5 + install.sh | 7 +- 28 files changed, 5690 insertions(+), 936 deletions(-) create mode 100644 backend/src/services/thumbnailService.js diff --git a/backend/.env.example b/backend/.env.example index 3156f23..4ad0dc4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/config.js b/backend/src/config.js index c8adce4..08195d3 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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') }; diff --git a/backend/src/db/database.js b/backend/src/db/database.js index d1c7828..6278e5d 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -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() { diff --git a/backend/src/index.js b/backend/src/index.js index 6ed3427..ad9dd52 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 = () => { diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 4c4c78f..46e7089 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -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 }); }) diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 0b721f8..3dc9e83 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -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 }); }) ); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 074c372..9ceea5e 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -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) => { diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index a40116a..24a8358 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -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 }); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index c77ec9c..4c9a894 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -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); diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js index c227706..7b50aeb 100644 --- a/backend/src/services/hardwareMonitorService.js +++ b/backend/src/services/hardwareMonitorService.js @@ -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 = []) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 82d89f6..8996161 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -5,6 +5,7 @@ const path = require('path'); const settingsService = require('./settingsService'); const omdbService = require('./omdbService'); const { getJobLogDir } = require('./logPathService'); +const thumbnailService = require('./thumbnailService'); function parseJsonSafe(raw, fallback = null) { if (!raw) { @@ -230,16 +231,17 @@ function normalizeMediaTypeValue(value) { ) { return 'dvd'; } - if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { - return 'other'; + if (raw === 'cd' || raw === 'audio_cd') { + return 'cd'; } return null; } -function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { +function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) { const mkInfo = parseInfoFromValue(makemkvInfo, null); const miInfo = parseInfoFromValue(mediainfoInfo, null); const plan = parseInfoFromValue(encodePlan, null); + const hbInfo = parseInfoFromValue(handbrakeInfo, null); const rawPath = String(job?.raw_path || '').trim(); const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim(); const profileHint = normalizeMediaTypeValue( @@ -251,10 +253,31 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { || job?.mediaType ); - if (profileHint === 'bluray' || profileHint === 'dvd') { + if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd') { return profileHint; } + const statusCandidates = [ + job?.status, + job?.last_state, + mkInfo?.lastState + ]; + if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) { + return 'cd'; + } + + const planFormat = String(plan?.format || '').trim().toLowerCase(); + const hasCdTracksInPlan = Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0; + if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) { + return 'cd'; + } + if (String(hbInfo?.mode || '').trim().toLowerCase() === 'cd_rip') { + return 'cd'; + } + if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) { + return 'cd'; + } + if (hasBlurayStructure(rawPath)) { return 'bluray'; } @@ -377,16 +400,22 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null); const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null); const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null); - const mediaType = inferMediaType(job, mkInfo, miInfo, plan); + const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null); + const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo); const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); const rawDir = String(effectiveSettings?.raw_dir || '').trim(); - const movieDir = String(effectiveSettings?.movie_dir || '').trim(); - const effectiveRawPath = rawDir && job?.raw_path - ? resolveEffectiveRawPath(job.raw_path, rawDir) - : (job?.raw_path || null); - const effectiveOutputPath = movieDir && job?.output_path - ? resolveEffectiveOutputPath(job.output_path, movieDir) - : (job?.output_path || null); + const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); + const movieDir = mediaType === 'cd' ? rawDir : configuredMovieDir; + const effectiveRawPath = mediaType === 'cd' + ? (job?.raw_path || null) + : (rawDir && job?.raw_path + ? resolveEffectiveRawPath(job.raw_path, rawDir) + : (job?.raw_path || null)); + const effectiveOutputPath = mediaType === 'cd' + ? (job?.output_path || null) + : (configuredMovieDir && job?.output_path + ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) + : (job?.output_path || null)); return { mediaType, @@ -396,6 +425,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = effectiveOutputPath, makemkvInfo: mkInfo, mediainfoInfo: miInfo, + handbrakeInfo, encodePlan: plan }; } @@ -421,15 +451,19 @@ function buildUnknownFileStatus(filePath = null) { function enrichJobRow(job, settings = null, options = {}) { const includeFsChecks = options?.includeFsChecks !== false; - const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null); const omdbInfo = parseJsonSafe(job.omdb_json, null); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const handbrakeInfo = resolvedPaths.handbrakeInfo; + const outputStatus = includeFsChecks + ? (resolvedPaths.mediaType === 'cd' + ? inspectDirectory(resolvedPaths.effectiveOutputPath) + : inspectOutputFile(resolvedPaths.effectiveOutputPath)) + : (resolvedPaths.mediaType === 'cd' + ? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath) + : buildUnknownFileStatus(resolvedPaths.effectiveOutputPath)); const rawStatus = includeFsChecks ? inspectDirectory(resolvedPaths.effectiveRawPath) : buildUnknownDirectoryStatus(resolvedPaths.effectiveRawPath); - const outputStatus = includeFsChecks - ? inspectOutputFile(resolvedPaths.effectiveOutputPath) - : buildUnknownFileStatus(resolvedPaths.effectiveOutputPath); const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null; const movieDirStatus = includeFsChecks ? inspectDirectory(movieDirPath) @@ -441,7 +475,9 @@ function enrichJobRow(job, settings = null, options = {}) { const ripSuccessful = Number(job?.rip_successful || 0) === 1 || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const backupSuccess = ripSuccessful; - const encodeSuccess = String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + const encodeSuccess = mediaType === 'cd' + ? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists)) + : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; return { ...job, @@ -584,6 +620,94 @@ function deleteFilesRecursively(rootPath, keepRoot = true) { return result; } +function normalizeJobIdValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function parseSourceJobIdFromPlan(encodePlanRaw) { + const plan = parseInfoFromValue(encodePlanRaw, null); + const sourceJobId = normalizeJobIdValue(plan?.sourceJobId); + return sourceJobId || null; +} + +function parseRetryLinkedJobIdsFromLogLines(lines = []) { + const jobIds = new Set(); + const list = Array.isArray(lines) ? lines : []; + for (const line of list) { + const text = String(line || ''); + if (!text) { + continue; + } + if (!/retry/i.test(text)) { + continue; + } + const regex = /job\s*#(\d+)/ig; + let match = regex.exec(text); + while (match) { + const id = normalizeJobIdValue(match?.[1]); + if (id) { + jobIds.add(id); + } + match = regex.exec(text); + } + } + return Array.from(jobIds); +} + +function normalizeLineageReason(value) { + const normalized = String(value || '').trim(); + return normalized || null; +} + +function inspectDeletionPath(targetPath) { + const normalized = normalizeComparablePath(targetPath); + if (!normalized) { + return { + path: null, + exists: false, + isDirectory: false, + isFile: false + }; + } + try { + const stat = fs.lstatSync(normalized); + return { + path: normalized, + exists: true, + isDirectory: stat.isDirectory(), + isFile: stat.isFile() + }; + } catch (_error) { + return { + path: normalized, + exists: false, + isDirectory: false, + isFile: false + }; + } +} + +function buildJobDisplayTitle(job = null) { + if (!job || typeof job !== 'object') { + return '-'; + } + return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-'; +} + +function isFilesystemRootPath(inputPath) { + const raw = String(inputPath || '').trim(); + if (!raw) { + return false; + } + const resolved = normalizeComparablePath(raw); + const parsedRoot = path.parse(resolved).root; + return Boolean(parsedRoot && resolved === normalizeComparablePath(parsedRoot)); +} + class HistoryService { async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) { const db = await getDb(); @@ -642,6 +766,186 @@ class HistoryService { return result.changes; } + async listJobLineageArtifactsByJobIds(jobIds = []) { + const normalizedIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedIds.length === 0) { + return new Map(); + } + + const db = await getDb(); + const placeholders = normalizedIds.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + FROM job_lineage_artifacts + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + normalizedIds + ); + + const byJobId = new Map(); + for (const row of rows) { + const ownerJobId = normalizeJobIdValue(row?.job_id); + if (!ownerJobId) { + continue; + } + if (!byJobId.has(ownerJobId)) { + byJobId.set(ownerJobId, []); + } + byJobId.get(ownerJobId).push({ + id: normalizeJobIdValue(row?.id), + jobId: ownerJobId, + sourceJobId: normalizeJobIdValue(row?.source_job_id), + mediaType: normalizeMediaTypeValue(row?.media_type), + rawPath: String(row?.raw_path || '').trim() || null, + outputPath: String(row?.output_path || '').trim() || null, + reason: normalizeLineageReason(row?.reason), + note: String(row?.note || '').trim() || null, + createdAt: String(row?.created_at || '').trim() || null + }); + } + + return byJobId; + } + + async transferJobLineageArtifacts(sourceJobId, replacementJobId, options = {}) { + const fromJobId = normalizeJobIdValue(sourceJobId); + const toJobId = normalizeJobIdValue(replacementJobId); + if (!fromJobId || !toJobId || fromJobId === toJobId) { + const error = new Error('Ungültige Job-IDs für Lineage-Transfer.'); + error.statusCode = 400; + throw error; + } + + const reason = normalizeLineageReason(options?.reason) || 'job_replaced'; + const note = String(options?.note || '').trim() || null; + const sourceJob = await this.getJobById(fromJobId); + if (!sourceJob) { + const error = new Error(`Quell-Job ${fromJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, sourceJob); + const rawPath = String(resolvedPaths?.effectiveRawPath || sourceJob?.raw_path || '').trim() || null; + const outputPath = String(resolvedPaths?.effectiveOutputPath || sourceJob?.output_path || '').trim() || null; + const mediaType = normalizeMediaTypeValue(resolvedPaths?.mediaType) || 'other'; + + const db = await getDb(); + await db.exec('BEGIN'); + try { + await db.run( + ` + INSERT INTO job_lineage_artifacts ( + job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + ) + SELECT ?, source_job_id, media_type, raw_path, output_path, reason, note, created_at + FROM job_lineage_artifacts + WHERE job_id = ? + `, + [toJobId, fromJobId] + ); + + if (rawPath || outputPath) { + await db.run( + ` + INSERT INTO job_lineage_artifacts ( + job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, + [toJobId, fromJobId, mediaType, rawPath, outputPath, reason, note] + ); + } + + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + logger.info('job:lineage:transferred', { + sourceJobId: fromJobId, + replacementJobId: toJobId, + mediaType, + reason, + hasRawPath: Boolean(rawPath), + hasOutputPath: Boolean(outputPath) + }); + } + + async retireJobInFavorOf(sourceJobId, replacementJobId, options = {}) { + const fromJobId = normalizeJobIdValue(sourceJobId); + const toJobId = normalizeJobIdValue(replacementJobId); + if (!fromJobId || !toJobId || fromJobId === toJobId) { + const error = new Error('Ungültige Job-IDs für Job-Ersatz.'); + error.statusCode = 400; + throw error; + } + + const reason = normalizeLineageReason(options?.reason) || 'job_replaced'; + const note = String(options?.note || '').trim() || null; + + await this.transferJobLineageArtifacts(fromJobId, toJobId, { reason, note }); + + const db = await getDb(); + const pipelineRow = await db.get('SELECT active_job_id FROM pipeline_state WHERE id = 1'); + const activeJobId = normalizeJobIdValue(pipelineRow?.active_job_id); + const sourceIsActive = activeJobId === fromJobId; + + await db.exec('BEGIN'); + try { + if (sourceIsActive) { + await db.run( + ` + UPDATE pipeline_state + SET active_job_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, + [toJobId] + ); + } else { + await db.run( + ` + UPDATE pipeline_state + SET active_job_id = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND active_job_id = ? + `, + [fromJobId] + ); + } + + await db.run('DELETE FROM jobs WHERE id = ?', [fromJobId]); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + await this.closeProcessLog(fromJobId); + this._deleteProcessLogFile(fromJobId); + + logger.warn('job:retired', { + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason, + sourceWasActive: sourceIsActive + }); + + return { + retired: true, + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason + }; + } + appendLog(jobId, source, message) { this.appendProcessLog(jobId, source, message); } @@ -822,8 +1126,8 @@ class HistoryService { } if (filters.search) { - where.push('(title LIKE ? OR imdb_id LIKE ? OR detected_title LIKE ?)'); - values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`); + where.push('(title LIKE ? OR imdb_id LIKE ? OR detected_title LIKE ? OR makemkv_info_json LIKE ?)'); + values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`); } const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; @@ -884,7 +1188,7 @@ class HistoryService { ` SELECT * FROM jobs - WHERE status IN ('RIPPING', 'ENCODING') + WHERE status IN ('RIPPING', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC ` ), @@ -903,7 +1207,7 @@ class HistoryService { ` SELECT * FROM jobs - WHERE status = 'ENCODING' + WHERE status IN ('ENCODING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC ` ), @@ -915,6 +1219,22 @@ class HistoryService { })); } + async getRunningFilmEncodeJobs() { + const db = await getDb(); + const rows = await db.all( + `SELECT id, status FROM jobs WHERE status = 'ENCODING' ORDER BY updated_at ASC, id ASC` + ); + return rows; + } + + async getRunningCdEncodeJobs() { + const db = await getDb(); + const rows = await db.all( + `SELECT id, status FROM jobs WHERE status IN ('CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC` + ); + return rows; + } + async getJobWithLogs(jobId, options = {}) { const db = await getDb(); const includeFsChecks = options?.includeFsChecks !== false; @@ -1189,13 +1509,14 @@ class HistoryService { } } + const orphanPosterUrl = omdbById?.poster || null; await this.updateJob(created.id, { status: 'FINISHED', last_state: 'FINISHED', title: omdbById?.title || metadata.title || null, year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : metadata.year, imdb_id: omdbById?.imdbId || metadata.imdbId || null, - poster_url: omdbById?.poster || null, + poster_url: orphanPosterUrl, omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null, selected_from_omdb: omdbById ? 1 : 0, rip_successful: 1, @@ -1216,6 +1537,16 @@ class HistoryService { }) }); + // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) + if (orphanPosterUrl) { + thumbnailService.cacheJobThumbnail(created.id, orphanPosterUrl) + .then(() => { + const promotedUrl = thumbnailService.promoteJobThumbnail(created.id); + if (promotedUrl) return this.updateJob(created.id, { poster_url: promotedUrl }); + }) + .catch(() => {}); + } + await this.appendLog( created.id, 'SYSTEM', @@ -1289,6 +1620,11 @@ class HistoryService { selected_from_omdb: selectedFromOmdb }); + // Bild in Cache laden (async, blockiert nicht) + if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + thumbnailService.cacheJobThumbnail(jobId, posterUrl).catch(() => {}); + } + await this.appendLog( jobId, 'USER_ACTION', @@ -1304,6 +1640,514 @@ class HistoryService { return enrichJobRow(updated, settings); } + async _resolveRelatedJobsForDeletion(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const db = await getDb(); + const rows = await db.all('SELECT * FROM jobs ORDER BY id ASC'); + const byId = new Map(rows.map((row) => [Number(row.id), row])); + const primary = byId.get(normalizedJobId); + if (!primary) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + if (!includeRelated) { + return [primary]; + } + + const childrenByParent = new Map(); + const childrenBySource = new Map(); + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const parentJobId = normalizeJobIdValue(row?.parent_job_id); + if (parentJobId) { + if (!childrenByParent.has(parentJobId)) { + childrenByParent.set(parentJobId, new Set()); + } + childrenByParent.get(parentJobId).add(rowId); + } + const sourceJobId = parseSourceJobIdFromPlan(row?.encode_plan_json); + if (sourceJobId) { + if (!childrenBySource.has(sourceJobId)) { + childrenBySource.set(sourceJobId, new Set()); + } + childrenBySource.get(sourceJobId).add(rowId); + } + } + + const pending = [normalizedJobId]; + const visited = new Set(); + const enqueue = (value) => { + const id = normalizeJobIdValue(value); + if (!id || visited.has(id)) { + return; + } + pending.push(id); + }; + + while (pending.length > 0) { + const currentId = normalizeJobIdValue(pending.shift()); + if (!currentId || visited.has(currentId)) { + continue; + } + visited.add(currentId); + + const row = byId.get(currentId); + if (!row) { + continue; + } + + enqueue(row.parent_job_id); + enqueue(parseSourceJobIdFromPlan(row.encode_plan_json)); + + for (const childId of (childrenByParent.get(currentId) || [])) { + enqueue(childId); + } + for (const childId of (childrenBySource.get(currentId) || [])) { + enqueue(childId); + } + + try { + const processLog = await this.readProcessLogLines(currentId, { includeAll: true }); + const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines); + for (const linkedId of linkedJobIds) { + enqueue(linkedId); + } + } catch (_error) { + // optional fallback links from process logs; ignore read errors + } + } + + return Array.from(visited) + .map((id) => byId.get(id)) + .filter(Boolean) + .sort((left, right) => Number(left.id || 0) - Number(right.id || 0)); + } + + _collectDeleteCandidatesForJob(job, settings = null, options = {}) { + const normalizedJobId = normalizeJobIdValue(job?.id); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const lineageArtifacts = Array.isArray(options?.lineageArtifacts) ? options.lineageArtifacts : []; + const toNormalizedPath = (value) => { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + return normalizeComparablePath(raw); + }; + const unique = (values = []) => Array.from(new Set((Array.isArray(values) ? values : []).filter(Boolean))); + const sanitizeRoots = (values = []) => unique(values).filter((root) => !isFilesystemRootPath(root)); + + const artifactRawPaths = lineageArtifacts + .map((artifact) => toNormalizedPath(artifact?.rawPath)) + .filter(Boolean); + const artifactMoviePaths = lineageArtifacts + .map((artifact) => toNormalizedPath(artifact?.outputPath)) + .filter(Boolean); + + const explicitRawPaths = unique([ + toNormalizedPath(job?.raw_path), + toNormalizedPath(resolvedPaths?.effectiveRawPath), + ...artifactRawPaths + ]); + const explicitMoviePaths = unique([ + toNormalizedPath(job?.output_path), + toNormalizedPath(resolvedPaths?.effectiveOutputPath), + ...artifactMoviePaths + ]); + + const rawRoots = sanitizeRoots([ + ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), + toNormalizedPath(resolvedPaths?.rawDir), + ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) + ]); + const movieRoots = sanitizeRoots([ + ...getConfiguredMediaPathList(settings || {}, 'movie_dir'), + toNormalizedPath(resolvedPaths?.movieDir), + ...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) + ]); + + const rawCandidates = []; + const movieCandidates = []; + const addCandidate = (bucket, target, candidatePath, source, allowedRoots = []) => { + const normalizedPath = toNormalizedPath(candidatePath); + if (!normalizedPath) { + return; + } + if (isFilesystemRootPath(normalizedPath)) { + return; + } + const roots = Array.isArray(allowedRoots) ? allowedRoots.filter(Boolean) : []; + if (roots.length > 0 && !roots.some((root) => isPathInside(root, normalizedPath))) { + return; + } + bucket.push({ + target, + path: normalizedPath, + source, + jobId: normalizedJobId + }); + }; + + const artifactRawPathSet = new Set(artifactRawPaths); + for (const rawPath of explicitRawPaths) { + addCandidate( + rawCandidates, + 'raw', + rawPath, + artifactRawPathSet.has(rawPath) ? 'lineage_raw_path' : 'raw_path', + rawRoots + ); + } + + const rawFolderNames = new Set(); + for (const rawPath of explicitRawPaths) { + const folderName = String(path.basename(rawPath || '') || '').trim(); + if (!folderName || folderName === '.' || folderName === path.sep) { + continue; + } + rawFolderNames.add(folderName); + const stripped = stripRawFolderStatePrefix(folderName); + if (stripped) { + rawFolderNames.add(stripped); + rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX)); + rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX)); + } + } + for (const rootPath of rawRoots) { + for (const folderName of rawFolderNames) { + addCandidate(rawCandidates, 'raw', path.join(rootPath, folderName), 'raw_variant', rawRoots); + } + } + + if (normalizedJobId) { + for (const rootPath of rawRoots) { + try { + if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) { + continue; + } + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const metadata = parseRawFolderMetadata(entry.name); + if (normalizeJobIdValue(metadata?.folderJobId) === normalizedJobId) { + addCandidate( + rawCandidates, + 'raw', + path.join(rootPath, entry.name), + 'raw_jobid_scan', + rawRoots + ); + } + } + } catch (_error) { + // ignore fs errors while collecting optional candidates + } + } + } + + const artifactMoviePathSet = new Set(artifactMoviePaths); + for (const outputPath of explicitMoviePaths) { + addCandidate( + movieCandidates, + 'movie', + outputPath, + artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', + movieRoots + ); + const parentDir = toNormalizedPath(path.dirname(outputPath)); + if (parentDir && !movieRoots.includes(parentDir)) { + addCandidate( + movieCandidates, + 'movie', + parentDir, + artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', + movieRoots + ); + } + } + + if (normalizedJobId) { + const incompleteName = `Incomplete_job-${normalizedJobId}`; + for (const rootPath of movieRoots) { + addCandidate(movieCandidates, 'movie', path.join(rootPath, incompleteName), 'movie_incomplete_folder', movieRoots); + try { + if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) { + continue; + } + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const match = String(entry.name || '').match(/^incomplete_job-(\d+)\s*$/i); + if (normalizeJobIdValue(match?.[1]) !== normalizedJobId) { + continue; + } + addCandidate( + movieCandidates, + 'movie', + path.join(rootPath, entry.name), + 'movie_incomplete_scan', + movieRoots + ); + } + } catch (_error) { + // ignore fs errors while collecting optional candidates + } + } + } + + return { + rawCandidates, + movieCandidates, + rawRoots, + movieRoots + }; + } + + _buildDeletePreviewFromJobs(jobs = [], settings = null, lineageArtifactsByJobId = null) { + const rows = Array.isArray(jobs) ? jobs : []; + const artifactsMap = lineageArtifactsByJobId instanceof Map ? lineageArtifactsByJobId : new Map(); + const candidateMap = new Map(); + const protectedRoots = { + raw: new Set(), + movie: new Set() + }; + const upsertCandidate = (candidate) => { + const target = String(candidate?.target || '').trim().toLowerCase(); + const candidatePath = String(candidate?.path || '').trim(); + if (!target || !candidatePath) { + return; + } + const key = `${target}:${candidatePath}`; + if (!candidateMap.has(key)) { + candidateMap.set(key, { + target, + path: candidatePath, + jobIds: new Set(), + sources: new Set() + }); + } + const row = candidateMap.get(key); + const candidateJobId = normalizeJobIdValue(candidate?.jobId); + if (candidateJobId) { + row.jobIds.add(candidateJobId); + } + const source = String(candidate?.source || '').trim(); + if (source) { + row.sources.add(source); + } + }; + + for (const job of rows) { + const lineageArtifacts = artifactsMap.get(normalizeJobIdValue(job?.id)) || []; + const collected = this._collectDeleteCandidatesForJob(job, settings, { lineageArtifacts }); + for (const rootPath of collected.rawRoots || []) { + protectedRoots.raw.add(rootPath); + } + for (const rootPath of collected.movieRoots || []) { + protectedRoots.movie.add(rootPath); + } + for (const candidate of collected.rawCandidates || []) { + upsertCandidate(candidate); + } + for (const candidate of collected.movieCandidates || []) { + upsertCandidate(candidate); + } + } + + const buildList = (target) => Array.from(candidateMap.values()) + .filter((row) => row.target === target) + .map((row) => { + const inspection = inspectDeletionPath(row.path); + return { + target, + path: row.path, + exists: Boolean(inspection.exists), + isDirectory: Boolean(inspection.isDirectory), + isFile: Boolean(inspection.isFile), + jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right)) + }; + }) + .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + + return { + pathCandidates: { + raw: buildList('raw'), + movie: buildList('movie') + }, + protectedRoots: { + raw: Array.from(protectedRoots.raw).sort((left, right) => left.localeCompare(right)), + movie: Array.from(protectedRoots.movie).sort((left, right) => left.localeCompare(right)) + } + }; + } + + async getJobDeletePreview(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated }); + const settings = await settingsService.getSettingsMap(); + const lineageArtifactsByJobId = await this.listJobLineageArtifactsByJobIds( + jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean) + ); + const preview = this._buildDeletePreviewFromJobs(jobs, settings, lineageArtifactsByJobId); + const relatedJobs = jobs.map((job) => ({ + id: Number(job.id), + parentJobId: normalizeJobIdValue(job.parent_job_id), + title: buildJobDisplayTitle(job), + status: String(job.status || '').trim() || null, + isPrimary: Number(job.id) === normalizedJobId, + createdAt: String(job.created_at || '').trim() || null + })); + const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length; + const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; + + return { + jobId: normalizedJobId, + includeRelated, + relatedJobs, + pathCandidates: preview.pathCandidates, + protectedRoots: preview.protectedRoots, + counts: { + relatedJobs: relatedJobs.length, + rawCandidates: preview.pathCandidates.raw.length, + movieCandidates: preview.pathCandidates.movie.length, + existingRawCandidates, + existingMovieCandidates + } + }; + } + + _deletePathsFromPreview(preview, target = 'both') { + const normalizedTarget = String(target || 'both').trim().toLowerCase(); + const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both'; + const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both'; + + const summary = { + target: normalizedTarget, + raw: { attempted: includesRaw, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, + movie: { attempted: includesMovie, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, + deletedPaths: [] + }; + + const applyTarget = (targetKey) => { + const candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : []) + .filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile))); + if (candidates.length === 0) { + summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.'; + return; + } + + const protectedRoots = new Set( + (Array.isArray(preview?.protectedRoots?.[targetKey]) ? preview.protectedRoots[targetKey] : []) + .map((rootPath) => String(rootPath || '').trim()) + .filter(Boolean) + .map((rootPath) => normalizeComparablePath(rootPath)) + ); + + const orderedCandidates = [...candidates].sort( + (left, right) => String(right?.path || '').length - String(left?.path || '').length + ); + for (const candidate of orderedCandidates) { + const candidatePath = String(candidate?.path || '').trim(); + if (!candidatePath) { + continue; + } + const inspection = inspectDeletionPath(candidatePath); + if (!inspection.exists) { + continue; + } + + if (inspection.isDirectory) { + const keepRoot = protectedRoots.has(inspection.path); + const result = deleteFilesRecursively(inspection.path, keepRoot); + const filesDeleted = Number(result?.filesDeleted || 0); + const dirsRemoved = Number(result?.dirsRemoved || 0); + const directoryRemoved = !keepRoot && !fs.existsSync(inspection.path); + const changed = filesDeleted > 0 || dirsRemoved > 0 || directoryRemoved; + summary[targetKey].filesDeleted += filesDeleted; + summary[targetKey].dirsRemoved += dirsRemoved; + if (changed) { + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'directory', + keepRoot, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + continue; + } + + fs.unlinkSync(inspection.path); + summary[targetKey].filesDeleted += 1; + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'file', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + + summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0 + || summary[targetKey].filesDeleted > 0 + || summary[targetKey].dirsRemoved > 0; + if (!summary[targetKey].deleted) { + summary[targetKey].reason = 'Keine vorhandenen Dateien/Ordner gelöscht.'; + } + }; + + if (includesRaw) { + applyTarget('raw'); + } + if (includesMovie) { + applyTarget('movie'); + } + + return summary; + } + + _deleteProcessLogFile(jobId) { + const processLogPath = toProcessLogPath(jobId); + if (!processLogPath || !fs.existsSync(processLogPath)) { + return; + } + try { + fs.unlinkSync(processLogPath); + } catch (error) { + logger.warn('job:process-log:delete-failed', { + jobId, + path: processLogPath, + error: error?.message || String(error) + }); + } + } + async deleteJobFiles(jobId, target = 'both') { const allowedTargets = new Set(['raw', 'movie', 'both']); if (!allowedTargets.has(target)) { @@ -1346,7 +2190,10 @@ class HistoryService { } else if (!fs.existsSync(effectiveRawPath)) { summary.raw.reason = 'RAW-Pfad existiert nicht.'; } else { - const result = deleteFilesRecursively(effectiveRawPath, true); + const rawPath = normalizeComparablePath(effectiveRawPath); + const rawRoot = normalizeComparablePath(effectiveRawDir); + const keepRoot = rawPath === rawRoot; + const result = deleteFilesRecursively(effectiveRawPath, keepRoot); summary.raw.deleted = true; summary.raw.filesDeleted = result.filesDeleted; summary.raw.dirsRemoved = result.dirsRemoved; @@ -1417,7 +2264,7 @@ class HistoryService { }; } - async deleteJob(jobId, fileTarget = 'none') { + async deleteJob(jobId, fileTarget = 'none', options = {}) { const allowedTargets = new Set(['none', 'raw', 'movie', 'both']); if (!allowedTargets.has(fileTarget)) { const error = new Error(`Ungültiges target '${fileTarget}'. Erlaubt: none, raw, movie, both.`); @@ -1425,36 +2272,131 @@ class HistoryService { throw error; } - const existing = await this.getJobById(jobId); - if (!existing) { - const error = new Error('Job nicht gefunden.'); + const includeRelated = Boolean(options?.includeRelated); + if (!includeRelated) { + const existing = await this.getJobById(jobId); + if (!existing) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + let fileSummary = null; + if (fileTarget !== 'none') { + const preview = await this.getJobDeletePreview(jobId, { includeRelated: false }); + fileSummary = this._deletePathsFromPreview(preview, fileTarget); + } + + const db = await getDb(); + const pipelineRow = await db.get( + 'SELECT state, active_job_id FROM pipeline_state WHERE id = 1' + ); + + const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(jobId); + const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + + if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) { + const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); + error.statusCode = 409; + throw error; + } + + await db.exec('BEGIN'); + try { + if (isActivePipelineJob) { + await db.run( + ` + UPDATE pipeline_state + SET + state = 'IDLE', + active_job_id = NULL, + progress = 0, + eta = NULL, + status_text = 'Bereit', + context_json = '{}', + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + ` + ); + } else { + await db.run( + ` + UPDATE pipeline_state + SET + active_job_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND active_job_id = ? + `, + [jobId] + ); + } + + await db.run('DELETE FROM jobs WHERE id = ?', [jobId]); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + await this.closeProcessLog(jobId); + this._deleteProcessLogFile(jobId); + thumbnailService.deleteThumbnail(jobId); + + logger.warn('job:deleted', { + jobId, + fileTarget, + includeRelated: false, + pipelineStateReset: isActivePipelineJob, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId, + fileTarget, + includeRelated: false, + deletedJobIds: [Number(jobId)], + fileSummary + }; + } + + const normalizedJobId = normalizeJobIdValue(jobId); + const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); + const deleteJobIds = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + : []; + if (deleteJobIds.length === 0) { + const error = new Error('Keine löschbaren Historien-Einträge gefunden.'); error.statusCode = 404; throw error; } - let fileSummary = null; - if (fileTarget !== 'none') { - const fileResult = await this.deleteJobFiles(jobId, fileTarget); - fileSummary = fileResult.summary; - } - const db = await getDb(); - const pipelineRow = await db.get( - 'SELECT state, active_job_id FROM pipeline_state WHERE id = 1' - ); - - const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(jobId); - const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING']); - - if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) { + const pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1'); + const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id); + const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId)); + const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) { const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); error.statusCode = 409; throw error; } + let fileSummary = null; + if (fileTarget !== 'none') { + fileSummary = this._deletePathsFromPreview(preview, fileTarget); + } + await db.exec('BEGIN'); try { - if (isActivePipelineJob) { + if (activeJobIncluded) { await db.run( ` UPDATE pipeline_state @@ -1470,43 +2412,40 @@ class HistoryService { ` ); } else { + const placeholders = deleteJobIds.map(() => '?').join(', '); await db.run( ` UPDATE pipeline_state SET active_job_id = NULL, updated_at = CURRENT_TIMESTAMP - WHERE id = 1 AND active_job_id = ? + WHERE id = 1 AND active_job_id IN (${placeholders}) `, - [jobId] + deleteJobIds ); } - await db.run('DELETE FROM jobs WHERE id = ?', [jobId]); + const deletePlaceholders = deleteJobIds.map(() => '?').join(', '); + await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds); await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); throw error; } - await this.closeProcessLog(jobId); - const processLogPath = toProcessLogPath(jobId); - if (processLogPath && fs.existsSync(processLogPath)) { - try { - fs.unlinkSync(processLogPath); - } catch (error) { - logger.warn('job:process-log:delete-failed', { - jobId, - path: processLogPath, - error: error?.message || String(error) - }); - } + for (const deletedJobId of deleteJobIds) { + await this.closeProcessLog(deletedJobId); + this._deleteProcessLogFile(deletedJobId); + thumbnailService.deleteThumbnail(deletedJobId); } logger.warn('job:deleted', { - jobId, + jobId: normalizedJobId, fileTarget, - pipelineStateReset: isActivePipelineJob, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobCount: deleteJobIds.length, + pipelineStateReset: activeJobIncluded, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, @@ -1517,8 +2456,11 @@ class HistoryService { return { deleted: true, - jobId, + jobId: normalizedJobId, fileTarget, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobs: preview.relatedJobs, fileSummary }; } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 8932d86..ef6b371 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -21,6 +21,7 @@ const { buildMediainfoReview } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); const { errorToMeta } = require('../utils/errorMeta'); const userPresetService = require('./userPresetService'); +const thumbnailService = require('./thumbnailService'); const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); const REVIEW_REFRESH_SETTING_PREFIXES = [ @@ -29,8 +30,7 @@ const REVIEW_REFRESH_SETTING_PREFIXES = [ 'makemkv_rip_', 'makemkv_analyze_', 'output_extension_', - 'filename_template_', - 'output_folder_template_' + 'output_template_' ]; const REVIEW_REFRESH_SETTING_KEYS = new Set([ 'makemkv_min_length_minutes', @@ -41,11 +41,11 @@ const REVIEW_REFRESH_SETTING_KEYS = new Set([ 'makemkv_analyze_extra_args', 'makemkv_rip_extra_args', 'output_extension', - 'filename_template', - 'output_folder_template' + 'output_template' ]); const QUEUE_ACTIONS = { START_PREPARED: 'START_PREPARED', + START_CD: 'START_CD', RETRY: 'RETRY', REENCODE: 'REENCODE', RESTART_ENCODE: 'RESTART_ENCODE', @@ -56,7 +56,8 @@ const QUEUE_ACTION_LABELS = { [QUEUE_ACTIONS.RETRY]: 'Retry Rippen', [QUEUE_ACTIONS.REENCODE]: 'RAW neu encodieren', [QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten', - [QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen' + [QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen', + [QUEUE_ACTIONS.START_CD]: 'Audio CD starten' }; const PRE_ENCODE_PROGRESS_RESERVE = 10; const POST_ENCODE_PROGRESS_RESERVE = 10; @@ -85,6 +86,137 @@ function normalizeCdTrackText(value) { .trim(); } +function normalizePositiveInteger(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeCdTrackPositionList(values = []) { + const source = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of source) { + const normalized = normalizePositiveInteger(value); + if (!normalized) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function parseCdTrackDurationSec(track = null) { + const durationSec = Number(track?.durationSec); + if (Number.isFinite(durationSec) && durationSec > 0) { + return Math.max(0, Math.trunc(durationSec)); + } + const durationMs = Number(track?.durationMs); + if (Number.isFinite(durationMs) && durationMs > 0) { + return Math.max(0, Math.round(durationMs / 1000)); + } + return 0; +} + +function buildCdLiveTrackRows(selectedTrackPositions = [], tocTracks = [], fallbackArtist = null) { + const orderedPositions = normalizeCdTrackPositionList(selectedTrackPositions); + const byPosition = new Map( + (Array.isArray(tocTracks) ? tocTracks : []) + .map((track) => { + const position = normalizePositiveInteger(track?.position); + if (!position) { + return null; + } + return [position, track]; + }) + .filter(Boolean) + ); + + return orderedPositions.map((position, index) => { + const track = byPosition.get(position) || {}; + return { + order: index + 1, + position, + title: normalizeCdTrackText(track?.title) || `Track ${position}`, + artist: normalizeCdTrackText(track?.artist) || normalizeCdTrackText(fallbackArtist) || '', + durationSec: parseCdTrackDurationSec(track) + }; + }); +} + +function buildCdLiveProgressSnapshot({ + trackRows = [], + phase = 'rip', + trackIndex = 0, + trackTotal = null, + trackPosition = null, + ripCompletedCount = 0, + encodeCompletedCount = 0, + failedTrackPosition = null +}) { + const rows = Array.isArray(trackRows) ? trackRows : []; + const total = rows.length; + const normalizedPhase = String(phase || '').trim().toLowerCase() === 'encode' + ? 'encode' + : 'rip'; + const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || total; + const normalizedTrackIndex = normalizePositiveInteger(trackIndex); + const normalizedTrackPosition = normalizePositiveInteger(trackPosition); + const normalizedFailedTrackPosition = normalizePositiveInteger(failedTrackPosition); + const safeRipCompleted = Math.max(0, Math.min(total, Math.trunc(Number(ripCompletedCount) || 0))); + const safeEncodeCompleted = Math.max(0, Math.min(total, Math.trunc(Number(encodeCompletedCount) || 0))); + const selectedTrackPositions = rows.map((row) => row.position); + const ripCompletedTrackPositions = selectedTrackPositions.slice(0, safeRipCompleted); + const encodeCompletedTrackPositions = selectedTrackPositions.slice(0, safeEncodeCompleted); + + const trackStates = rows.map((row, index) => { + const ripDone = index < safeRipCompleted; + const encodeDone = index < safeEncodeCompleted; + let ripStatus = ripDone ? 'done' : 'pending'; + let encodeStatus = encodeDone ? 'done' : 'pending'; + + if (!ripDone && normalizedPhase === 'rip' && normalizedTrackPosition && row.position === normalizedTrackPosition) { + ripStatus = 'in_progress'; + } else if (!ripDone && normalizedPhase === 'rip' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { + ripStatus = 'error'; + } + + if (!encodeDone && normalizedPhase === 'encode' && normalizedTrackPosition && row.position === normalizedTrackPosition) { + encodeStatus = 'in_progress'; + } else if (!encodeDone && normalizedPhase === 'encode' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { + encodeStatus = 'error'; + } + + return { + ...row, + selected: true, + ripStatus, + encodeStatus + }; + }); + + return { + phase: normalizedPhase, + trackIndex: normalizedTrackIndex || 0, + trackTotal: normalizedTrackTotal, + trackPosition: normalizedTrackPosition || null, + ripCompleted: safeRipCompleted, + encodeCompleted: safeEncodeCompleted, + selectedTrackPositions, + ripCompletedTrackPositions, + encodeCompletedTrackPositions, + trackStates, + updatedAt: nowIso() + }; +} + function normalizeMediaProfile(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { @@ -117,9 +249,6 @@ function normalizeMediaProfile(value) { if (raw === 'cd' || raw === 'audio_cd') { return 'cd'; } - if (raw === 'disc' || raw === 'other' || raw === 'sonstiges') { - return 'other'; - } return null; } @@ -357,31 +486,46 @@ function resolveOutputTemplateValues(job, fallbackJobId = null) { }; } -function resolveOutputFileName(settings, values) { - const fileTemplate = settings.filename_template || '${title} (${year})'; - return sanitizeFileName(renderTemplate(fileTemplate, values)); -} +const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; -function resolveFinalOutputFolderName(settings, values) { - const folderTemplateRaw = String(settings.output_folder_template || '').trim(); - const fallbackTemplate = settings.filename_template || '${title} (${year})'; - const folderTemplate = folderTemplateRaw || fallbackTemplate; - return sanitizeFileName(renderTemplate(folderTemplate, values)); +function resolveOutputPathParts(settings, values) { + const template = String(settings.output_template || DEFAULT_OUTPUT_TEMPLATE).trim() + || DEFAULT_OUTPUT_TEMPLATE; + const rendered = renderTemplate(template, values); + const segments = rendered + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((seg) => sanitizeFileName(seg)) + .filter(Boolean); + + if (segments.length === 0) { + return { folderPath: '', baseName: 'untitled' }; + } + const baseName = segments[segments.length - 1]; + const folderParts = segments.slice(0, -1); + return { + folderPath: folderParts.length > 0 ? path.join(...folderParts) : '', + baseName + }; } function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); - const folderName = resolveFinalOutputFolderName(settings, values); - const baseName = resolveOutputFileName(settings, values); + const { folderPath, baseName } = resolveOutputPathParts(settings, values); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; - return path.join(movieDir, folderName, `${baseName}.${ext}`); + if (folderPath) { + return path.join(movieDir, folderPath, `${baseName}.${ext}`); + } + return path.join(movieDir, `${baseName}.${ext}`); } function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); - const baseName = resolveOutputFileName(settings, values); + const { baseName } = resolveOutputPathParts(settings, values); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 @@ -3552,13 +3696,15 @@ class PipelineService extends EventEmitter { async migrateRawFolderNamingOnStartup(db) { const settings = await settingsService.getSettingsMap(); - const rawBaseDir = String(settings?.raw_dir || '').trim(); + const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim(); const rawExtraDirs = [ settings?.raw_dir_bluray, settings?.raw_dir_dvd, - settings?.raw_dir_other + settings?.raw_dir_cd, + settingsService.DEFAULT_CD_DIR ].map((d) => String(d || '').trim()).filter(Boolean); - const allRawDirs = [rawBaseDir, ...rawExtraDirs].filter((d) => d && fs.existsSync(d)); + const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs] + .filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d)); if (allRawDirs.length === 0) { return; } @@ -3890,6 +4036,23 @@ class PipelineService extends EventEmitter { return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_jobs); } + async getMaxParallelCdEncodes() { + const settings = await settingsService.getSettingsMap(); + return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_cd_encodes ?? 2); + } + + async getMaxTotalEncodes() { + const settings = await settingsService.getSettingsMap(); + const value = Number(settings?.pipeline_max_total_encodes); + return Number.isFinite(value) && value >= 1 ? Math.min(24, Math.trunc(value)) : 3; + } + + async getCdBypassesQueue() { + const settings = await settingsService.getSettingsMap(); + const value = settings?.pipeline_cd_bypasses_queue; + return value === 'true' || value === true; + } + findQueueEntryIndexByJobId(jobId) { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } @@ -4090,9 +4253,15 @@ class PipelineService extends EventEmitter { } async getQueueSnapshot() { - const maxParallelJobs = await this.getMaxParallelJobs(); + const [maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue] = await Promise.all([ + this.getMaxParallelJobs(), + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue() + ]); const runningJobs = await historyService.getRunningJobs(); const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length; + const runningCdCount = runningJobs.filter((job) => ['CD_RIPPING', 'CD_ENCODING'].includes(job.status)).length; const queuedJobIds = this.queueEntries .filter((entry) => !entry.type || entry.type === 'job') .map((entry) => Number(entry.jobId)) @@ -4111,7 +4280,11 @@ class PipelineService extends EventEmitter { const queue = { maxParallelJobs, + maxParallelCdEncodes, + maxTotalEncodes, + cdBypassesQueue, runningCount: runningEncodeCount, + runningCdCount, runningJobs: runningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, @@ -4299,9 +4472,16 @@ class PipelineService extends EventEmitter { }; } - const maxParallelJobs = await this.getMaxParallelJobs(); - const runningEncodeJobs = await historyService.getRunningEncodeJobs(); - const shouldQueue = this.queueEntries.length > 0 || runningEncodeJobs.length >= maxParallelJobs; + const [maxFilm, maxTotal] = await Promise.all([ + this.getMaxParallelJobs(), + this.getMaxTotalEncodes() + ]); + const [filmRunning, cdRunning] = await Promise.all([ + historyService.getRunningFilmEncodeJobs().then((r) => r.length), + historyService.getRunningCdEncodeJobs().then((r) => r.length) + ]); + const totalRunning = filmRunning + cdRunning; + const shouldQueue = this.queueEntries.length > 0 || filmRunning >= maxFilm || totalRunning >= maxTotal; if (!shouldQueue) { const result = await startNow(); await this.emitQueueChanged(); @@ -4335,6 +4515,84 @@ class PipelineService extends EventEmitter { }; } + async enqueueOrStartCdAction(jobId, ripConfig, startNow) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID für CD Queue-Aktion.'); + error.statusCode = 400; + throw error; + } + if (typeof startNow !== 'function') { + const error = new Error('CD Queue-Aktion kann nicht gestartet werden (startNow fehlt).'); + error.statusCode = 500; + throw error; + } + + const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (existingQueueIndex >= 0) { + return { + queued: true, + started: false, + queuePosition: existingQueueIndex + 1, + action: QUEUE_ACTIONS.START_CD + }; + } + + const [maxCd, maxTotal, cdBypass] = await Promise.all([ + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue() + ]); + const [filmRunning, cdRunning] = await Promise.all([ + historyService.getRunningFilmEncodeJobs().then((r) => r.length), + historyService.getRunningCdEncodeJobs().then((r) => r.length) + ]); + const totalRunning = filmRunning + cdRunning; + + let shouldQueue; + if (cdBypass) { + const cdQueueLength = this.queueEntries.filter( + (e) => (!e.type || e.type === 'job') && e.action === QUEUE_ACTIONS.START_CD + ).length; + shouldQueue = cdQueueLength > 0 || cdRunning >= maxCd || totalRunning >= maxTotal; + } else { + shouldQueue = this.queueEntries.length > 0 || cdRunning >= maxCd || totalRunning >= maxTotal; + } + + if (!shouldQueue) { + const result = await startNow(); + await this.emitQueueChanged(); + return { + queued: false, + started: true, + action: QUEUE_ACTIONS.START_CD, + ...(result && typeof result === 'object' ? result : {}) + }; + } + + this.queueEntries.push({ + id: this.queueEntrySeq++, + jobId: normalizedJobId, + action: QUEUE_ACTIONS.START_CD, + ripConfig: ripConfig || {}, + enqueuedAt: nowIso() + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[QUEUE_ACTIONS.START_CD]}` + ); + await this.emitQueueChanged(); + void this.pumpQueue(); + + return { + queued: true, + started: false, + queuePosition: this.queueEntries.length, + action: QUEUE_ACTIONS.START_CD + }; + } + async dispatchNonJobEntry(entry) { const type = entry?.type; logger.info('queue:non-job:dispatch', { type, entryId: entry?.id }); @@ -4447,6 +4705,9 @@ class PipelineService extends EventEmitter { case QUEUE_ACTIONS.RESTART_REVIEW: await this.restartReviewFromRaw(jobId, { immediate: true }); break; + case QUEUE_ACTIONS.START_CD: + await this.startCdRip(jobId, entry.ripConfig || {}); + break; default: { const error = new Error(`Unbekannte Queue-Aktion: ${String(action || '-')}`); error.statusCode = 400; @@ -4462,23 +4723,66 @@ class PipelineService extends EventEmitter { this.queuePumpRunning = true; try { while (this.queueEntries.length > 0) { - const firstEntry = this.queueEntries[0]; - const isNonJob = firstEntry?.type && firstEntry.type !== 'job'; + // Get current running counts and limits + const [filmRunning, cdRunning, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([ + historyService.getRunningFilmEncodeJobs().then((r) => r.length), + historyService.getRunningCdEncodeJobs().then((r) => r.length), + this.getMaxParallelJobs(), + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue() + ]); + const totalRunning = filmRunning + cdRunning; - if (!isNonJob) { - // Job entries: respect the parallel encode limit. - const maxParallelJobs = await this.getMaxParallelJobs(); - const runningEncodeJobs = await historyService.getRunningEncodeJobs(); - if (runningEncodeJobs.length >= maxParallelJobs) { + // Find next startable entry + let entryIndex = -1; + for (let i = 0; i < this.queueEntries.length; i++) { + const candidate = this.queueEntries[i]; + const isNonJob = candidate.type && candidate.type !== 'job'; + + if (isNonJob) { + // Non-job entries (script, chain, wait) always start immediately + entryIndex = i; break; } + + // Job entry: check hierarchical limits + if (totalRunning >= maxTotal) { + // Total limit reached – nothing can start + break; + } + + const isCdEntry = candidate.action === QUEUE_ACTIONS.START_CD; + if (isCdEntry) { + if (cdRunning < maxCd) { + entryIndex = i; + break; + } + // CD limit reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked CD entry + } else { + // Film/video job entry + if (filmRunning < maxFilm) { + entryIndex = i; + break; + } + // Film limit reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked film entry + } } - const entry = this.queueEntries.shift(); + if (entryIndex < 0) { + break; // Nothing can start right now + } + + const entry = this.queueEntries.splice(entryIndex, 1)[0]; if (!entry) { break; } + const isNonJob = entry.type && entry.type !== 'job'; await this.emitQueueChanged(); try { if (isNonJob) { @@ -4493,7 +4797,7 @@ class PipelineService extends EventEmitter { await this.dispatchQueuedEntry(entry); } catch (error) { if (Number(error?.statusCode || 0) === 409) { - this.queueEntries.unshift(entry); + this.queueEntries.splice(entryIndex, 0, entry); await this.emitQueueChanged(); break; } @@ -4605,6 +4909,9 @@ class PipelineService extends EventEmitter { async setState(state, patch = {}) { const previous = this.snapshot.state; const previousActiveJobId = this.snapshot.activeJobId; + const contextPatch = patch.context && typeof patch.context === 'object' && !Array.isArray(patch.context) + ? patch.context + : null; this.snapshot = { ...this.snapshot, state, @@ -4617,12 +4924,29 @@ class PipelineService extends EventEmitter { // Keep per-job progress map in sync when a job starts or finishes. if (patch.activeJobId != null) { - this.jobProgress.set(Number(patch.activeJobId), { + const activeJobId = Number(patch.activeJobId); + const previousJobProgress = this.jobProgress.get(activeJobId) || {}; + const mergedContext = contextPatch + ? { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + ...contextPatch + } + : (previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : null); + const nextProgress = { + ...previousJobProgress, state, progress: patch.progress ?? 0, eta: patch.eta ?? null, statusText: patch.statusText ?? null - }); + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextProgress.context = mergedContext; + } + this.jobProgress.set(activeJobId, nextProgress); } else if (patch.activeJobId === null) { // Job slot cleared – remove the finished job's live entry so it falls // back to DB data in the frontend. @@ -4684,31 +5008,61 @@ class PipelineService extends EventEmitter { ); } - async updateProgress(stage, percent, eta, statusText, jobIdOverride = null) { + async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) { const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId; const effectiveProgress = percent ?? this.snapshot.progress; const effectiveEta = eta ?? this.snapshot.eta; const effectiveStatusText = statusText ?? this.snapshot.statusText; + const progressOptions = options && typeof options === 'object' ? options : {}; + const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object' + && !Array.isArray(progressOptions.contextPatch) + ? progressOptions.contextPatch + : null; // Update per-job progress so concurrent jobs don't overwrite each other. if (effectiveJobId != null) { - this.jobProgress.set(effectiveJobId, { + const previousJobProgress = this.jobProgress.get(effectiveJobId) || {}; + const mergedContext = contextPatch + ? { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + ...contextPatch + } + : (previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : null); + const nextProgress = { + ...previousJobProgress, state: stage, progress: effectiveProgress, eta: effectiveEta, statusText: effectiveStatusText - }); + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextProgress.context = mergedContext; + } + this.jobProgress.set(effectiveJobId, nextProgress); } // Only update the global snapshot fields when this update belongs to the // currently active job (avoids the snapshot jumping between parallel jobs). if (effectiveJobId === this.snapshot.activeJobId || effectiveJobId == null) { + const nextContext = contextPatch + ? { + ...(this.snapshot.context && typeof this.snapshot.context === 'object' + ? this.snapshot.context + : {}), + ...contextPatch + } + : this.snapshot.context; this.snapshot = { ...this.snapshot, state: stage, progress: effectiveProgress, eta: effectiveEta, - statusText: effectiveStatusText + statusText: effectiveStatusText, + context: nextContext }; await this.persistSnapshot(false); } @@ -4730,7 +5084,8 @@ class PipelineService extends EventEmitter { activeJobId: effectiveJobId, progress: effectiveProgress, eta: effectiveEta, - statusText: effectiveStatusText + statusText: effectiveStatusText, + contextPatch }); } @@ -4977,7 +5332,8 @@ class PipelineService extends EventEmitter { const keys = Array.isArray(changedKeys) ? changedKeys.map((item) => String(item || '').trim()).filter(Boolean) : []; - if (keys.includes('pipeline_max_parallel_jobs')) { + const queueLimitKeys = ['pipeline_max_parallel_jobs', 'pipeline_max_parallel_cd_encodes', 'pipeline_max_total_encodes', 'pipeline_cd_bypasses_queue']; + if (keys.some((k) => queueLimitKeys.includes(k))) { await this.emitQueueChanged(); void this.pumpQueue(); } @@ -5030,11 +5386,10 @@ class PipelineService extends EventEmitter { } const refreshSettings = await settingsService.getSettingsMap(); - const refreshRawBaseDir = String(refreshSettings?.raw_dir || '').trim(); + const refreshRawBaseDir = settingsService.DEFAULT_RAW_DIR; const refreshRawExtraDirs = [ refreshSettings?.raw_dir_bluray, - refreshSettings?.raw_dir_dvd, - refreshSettings?.raw_dir_other + refreshSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedRefreshRawPath = job.raw_path ? this.resolveCurrentRawPath(refreshRawBaseDir, job.raw_path, refreshRawExtraDirs) @@ -6488,6 +6843,11 @@ class PipelineService extends EventEmitter { makemkv_info_json: JSON.stringify(updatedMakemkvInfo) }); + // Bild in Cache laden (async, blockiert nicht) + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + thumbnailService.cacheJobThumbnail(jobId, posterValue).catch(() => {}); + } + const runningJobs = await historyService.getRunningJobs(); const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(jobId)); const keepCurrentPipelineSession = foreignRunningJobs.length > 0; @@ -6982,8 +7342,7 @@ class PipelineService extends EventEmitter { const confirmRawBaseDir = String(confirmSettings?.raw_dir || '').trim(); const confirmRawExtraDirs = [ confirmSettings?.raw_dir_bluray, - confirmSettings?.raw_dir_dvd, - confirmSettings?.raw_dir_other + confirmSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedConfirmRawPath = job.raw_path ? this.resolveCurrentRawPath(confirmRawBaseDir, job.raw_path, confirmRawExtraDirs) @@ -7102,11 +7461,10 @@ class PipelineService extends EventEmitter { } const reencodeSettings = await settingsService.getSettingsMap(); - const reencodeRawBaseDir = String(reencodeSettings?.raw_dir || '').trim(); + const reencodeRawBaseDir = settingsService.DEFAULT_RAW_DIR; const reencodeRawExtraDirs = [ reencodeSettings?.raw_dir_bluray, - reencodeSettings?.raw_dir_dvd, - reencodeSettings?.raw_dir_other + reencodeSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedReencodeRawPath = this.resolveCurrentRawPath(reencodeRawBaseDir, sourceJob.raw_path, reencodeRawExtraDirs); if (!resolvedReencodeRawPath) { @@ -7618,6 +7976,7 @@ class PipelineService extends EventEmitter { async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; + const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; if (scriptIds.length === 0 && chainIds.length === 0) { return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; } @@ -7668,7 +8027,7 @@ class PipelineService extends EventEmitter { }); const runInfo = await this.runCommand({ jobId, - stage: 'ENCODING', + stage: executionStage, source: 'PRE_ENCODE_SCRIPT', cmd: prepared.cmd, args: prepared.args, @@ -7753,6 +8112,7 @@ class PipelineService extends EventEmitter { async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; + const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; if (scriptIds.length === 0 && chainIds.length === 0) { return { configured: 0, @@ -7828,7 +8188,7 @@ class PipelineService extends EventEmitter { }); const runInfo = await this.runCommand({ jobId, - stage: 'ENCODING', + stage: executionStage, source: 'POST_ENCODE_SCRIPT', cmd: prepared.cmd, args: prepared.args, @@ -7982,8 +8342,7 @@ class PipelineService extends EventEmitter { const rawBaseDir = String(settings.raw_dir || '').trim(); const rawExtraDirs = [ settings.raw_dir_bluray, - settings.raw_dir_dvd, - settings.raw_dir_other + settings.raw_dir_dvd ].map((item) => String(item || '').trim()).filter(Boolean); const resolvedRawPath = job.raw_path ? this.resolveCurrentRawPath(rawBaseDir, job.raw_path, rawExtraDirs) @@ -8394,6 +8753,12 @@ class PipelineService extends EventEmitter { error_message: null }); + // Thumbnail aus Cache in persistenten Ordner verschieben + const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (promotedUrl) { + await historyService.updateJob(jobId, { poster_url: promotedUrl }).catch(() => {}); + } + logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath }); const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen'; const finishedStatusText = postEncodeScriptsSummary.failed > 0 @@ -8795,39 +9160,235 @@ class PipelineService extends EventEmitter { logger.info('retry:start', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); - const job = await historyService.getJobById(jobId); - if (!job) { + let sourceJob = await historyService.getJobById(jobId); + if (!sourceJob) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } - if (!job.title && !job.detected_title) { + if (!sourceJob.title && !sourceJob.detected_title) { const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.'); error.statusCode = 400; throw error; } - await historyService.resetProcessLog(jobId); + const sourceStatus = String(sourceJob.status || '').trim().toUpperCase(); + const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase(); + const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) + || ['ERROR', 'CANCELLED'].includes(sourceLastState); + if (!retryable) { + const error = new Error( + `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` + ); + error.statusCode = 409; + throw error; + } - await historyService.updateJob(jobId, { - status: 'RIPPING', - last_state: 'RIPPING', + const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { + makemkvInfo: sourceMakemkvInfo, + encodePlan: sourceEncodePlan + }); + const isCdRetry = mediaProfile === 'cd'; + + let cdRetryConfig = null; + if (isCdRetry) { + const normalizeTrackPosition = (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks) + ? sourceMakemkvInfo.tracks + : (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []); + if (sourceTracks.length === 0) { + const error = new Error('Retry nicht möglich: keine CD-Trackdaten im Quelljob vorhanden.'); + error.statusCode = 400; + throw error; + } + const selectedTracks = normalizeCdTrackPositionList( + Array.isArray(sourceEncodePlan?.selectedTracks) + ? sourceEncodePlan.selectedTracks + : sourceTracks.filter((track) => track?.selected !== false).map((track) => normalizeTrackPosition(track?.position)) + ); + const selectedMetadata = sourceMakemkvInfo?.selectedMetadata && typeof sourceMakemkvInfo.selectedMetadata === 'object' + ? sourceMakemkvInfo.selectedMetadata + : {}; + cdRetryConfig = { + format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', + formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' + ? sourceEncodePlan.formatOptions + : {}, + selectedTracks: selectedTracks.length > 0 + ? selectedTracks + : sourceTracks + .map((track) => normalizeTrackPosition(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0), + tracks: sourceTracks, + metadata: { + title: selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', + artist: selectedMetadata?.artist || null, + year: selectedMetadata?.year ?? sourceJob.year ?? null, + mbId: selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.mbid + || null, + coverUrl: selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || sourceJob.poster_url + || null + }, + selectedPreEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.preEncodeScriptIds || []), + selectedPostEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.postEncodeScriptIds || []), + selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []), + selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) + }; + } else { + const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const retryRawBaseDir = String(retrySettings?.raw_dir || '').trim(); + const retryRawExtraDirs = [ + retrySettings?.raw_dir_bluray, + retrySettings?.raw_dir_dvd + ].map((dirPath) => String(dirPath || '').trim()).filter(Boolean); + const resolvedOldRawPath = sourceJob.raw_path + ? this.resolveCurrentRawPath(retryRawBaseDir, sourceJob.raw_path, retryRawExtraDirs) + : null; + + if (resolvedOldRawPath) { + const oldRawFolderName = path.basename(resolvedOldRawPath); + const oldRawLooksLikeJobFolder = /\s-\sRAW\s-\sjob-\d+\s*$/i.test(stripRawStatePrefix(oldRawFolderName)); + if (!oldRawLooksLikeJobFolder) { + const error = new Error(`Retry nicht möglich: alter RAW-Pfad ist kein Job-RAW-Ordner (${resolvedOldRawPath}).`); + error.statusCode = 400; + throw error; + } + + const rawDeletionRoots = Array.from(new Set( + [ + retryRawBaseDir, + ...retryRawExtraDirs, + path.dirname(String(sourceJob.raw_path || '').trim()) + ] + .map((dirPath) => normalizeComparablePath(dirPath)) + .filter(Boolean) + )); + const oldRawPathAllowed = rawDeletionRoots.some((rootPath) => isPathInsideDirectory(rootPath, resolvedOldRawPath)); + if (!oldRawPathAllowed) { + const error = new Error( + `Retry nicht möglich: alter RAW-Pfad liegt außerhalb der erlaubten RAW-Verzeichnisse (${resolvedOldRawPath}).` + ); + error.statusCode = 400; + throw error; + } + + try { + fs.rmSync(resolvedOldRawPath, { recursive: true, force: true }); + } catch (deleteError) { + const error = new Error(`Retry nicht möglich: alter RAW-Ordner konnte nicht gelöscht werden (${deleteError.message}).`); + error.statusCode = 500; + throw error; + } + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Retry: alter RAW-Ordner wurde entfernt: ${resolvedOldRawPath}` + ); + sourceJob = await historyService.updateJob(jobId, { + raw_path: null, + rip_successful: 0 + }); + } else if (sourceJob.raw_path) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Retry: alter RAW-Pfad ist nicht mehr vorhanden und wird aus dem Job entfernt (${sourceJob.raw_path}).` + ); + sourceJob = await historyService.updateJob(jobId, { + raw_path: null, + rip_successful: 0 + }); + } + } + + const retryJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING', + detectedTitle: sourceJob.detected_title || sourceJob.title || null + }); + const retryJobId = Number(retryJob?.id || 0); + if (!Number.isFinite(retryJobId) || retryJobId <= 0) { + throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + const retryUpdatePayload = { + parent_job_id: Number(jobId), + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + makemkv_info_json: sourceJob.makemkv_info_json || null, + rip_successful: 0, error_message: null, end_time: null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: null, + encode_plan_json: isCdRetry + ? (sourceJob.encode_plan_json || null) + : null, encode_input_path: null, encode_review_confirmed: 0, - output_path: null - }); + output_path: null, + status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING', + last_state: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING' + }; + await historyService.updateJob(retryJobId, retryUpdatePayload); - this.startRipEncode(jobId).catch((error) => { - logger.error('retry:background-failed', { jobId, error: errorToMeta(error) }); - }); + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), retryJobId); + if (copiedUrl) { + await historyService.updateJob(retryJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } - return { started: true }; + await historyService.appendLog( + retryJobId, + 'USER_ACTION', + `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : 'Disc'}).` + ); + await historyService.retireJobInFavorOf(jobId, retryJobId, { + reason: isCdRetry ? 'cd_retry' : 'retry' + }); + this.cancelRequestedByJob.delete(retryJobId); + + if (isCdRetry) { + this.startCdRip(retryJobId, cdRetryConfig || {}).catch((error) => { + logger.error('retry:cd:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); + }); + } else { + this.startRipEncode(retryJobId).catch((error) => { + logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); + }); + } + + return { + started: true, + sourceJobId: Number(jobId), + jobId: retryJobId, + replacedSourceJob: true + }; } async resumeReadyToEncodeJob(jobId) { @@ -8862,8 +9423,7 @@ class PipelineService extends EventEmitter { const resumeRawBaseDir = String(resumeSettings?.raw_dir || '').trim(); const resumeRawExtraDirs = [ resumeSettings?.raw_dir_bluray, - resumeSettings?.raw_dir_dvd, - resumeSettings?.raw_dir_other + resumeSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedResumeRawPath = job.raw_path ? this.resolveCurrentRawPath(resumeRawBaseDir, job.raw_path, resumeRawExtraDirs) @@ -9056,8 +9616,7 @@ class PipelineService extends EventEmitter { const restartRawBaseDir = String(restartSettings?.raw_dir || '').trim(); const restartRawExtraDirs = [ restartSettings?.raw_dir_bluray, - restartSettings?.raw_dir_dvd, - restartSettings?.raw_dir_other + restartSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedRestartRawPath = job.raw_path ? this.resolveCurrentRawPath(restartRawBaseDir, job.raw_path, restartRawExtraDirs) @@ -9093,16 +9652,37 @@ class PipelineService extends EventEmitter { ? Boolean(restartPlan?.encodeInputTitleId) : Boolean(inputPath); - await historyService.updateJob(jobId, { + const replacementJob = await historyService.createJob({ + discDevice: job.disc_device || null, + status: 'READY_TO_ENCODE', + detectedTitle: job.detected_title || job.title || null + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + title: job.title || null, + year: job.year ?? null, + imdb_id: job.imdb_id || null, + poster_url: job.poster_url || null, + omdb_json: job.omdb_json || null, + selected_from_omdb: Number(job.selected_from_omdb || 0), status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, end_time: null, output_path: null, + disc_device: job.disc_device || null, + raw_path: activeRestartRawPath || null, + rip_successful: Number(job.rip_successful || 0), + makemkv_info_json: job.makemkv_info_json || null, handbrake_info_json: null, + mediainfo_info_json: job.mediainfo_info_json || null, encode_plan_json: JSON.stringify(restartPlan), encode_input_path: inputPath, - ...(activeRestartRawPath ? { raw_path: activeRestartRawPath } : {}), encode_review_confirmed: 0 }); const loadedSelectionText = ( @@ -9122,10 +9702,13 @@ class PipelineService extends EventEmitter { } else { restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`; } - await historyService.appendLog(jobId, 'USER_ACTION', restartLogMessage); + await historyService.appendLog(replacementJobId, 'USER_ACTION', restartLogMessage); + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'restart_encode' + }); await this.setState('READY_TO_ENCODE', { - activeJobId: jobId, + activeJobId: replacementJobId, progress: 0, eta: null, statusText: hasEncodableTitle @@ -9133,17 +9716,17 @@ class PipelineService extends EventEmitter { ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten' : 'Vorherige Encode-Auswahl geladen - anpassen und Encoding starten') : (isPreRipMode - ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' + ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' : 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), context: { ...(this.snapshot.context || {}), - jobId, + jobId: replacementJobId, inputPath, hasEncodableTitle, reviewConfirmed: false, mode, mediaProfile: readyMediaProfile, - sourceJobId: restartPlan?.sourceJobId || null, + sourceJobId: Number(jobId), selectedMetadata, mediaInfoReview: restartPlan } @@ -9153,7 +9736,10 @@ class PipelineService extends EventEmitter { restarted: true, started: false, stage: 'READY_TO_ENCODE', - reviewConfirmed: false + reviewConfirmed: false, + sourceJobId: Number(jobId), + jobId: replacementJobId, + replacedSourceJob: true }; } @@ -9176,11 +9762,10 @@ class PipelineService extends EventEmitter { } const reviewSettings = await settingsService.getSettingsMap(); - const reviewRawBaseDir = String(reviewSettings?.raw_dir || '').trim(); + const reviewRawBaseDir = settingsService.DEFAULT_RAW_DIR; const reviewRawExtraDirs = [ reviewSettings?.raw_dir_bluray, - reviewSettings?.raw_dir_dvd, - reviewSettings?.raw_dir_other + reviewSettings?.raw_dir_dvd ].map((d) => String(d || '').trim()).filter(Boolean); const resolvedReviewRawPath = this.resolveCurrentRawPath(reviewRawBaseDir, sourceJob.raw_path, reviewRawExtraDirs); if (!resolvedReviewRawPath) { @@ -9233,18 +9818,13 @@ class PipelineService extends EventEmitter { } const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId)); + let removedQueueActionLabel = null; if (staleQueueIndex >= 0) { const [removed] = this.queueEntries.splice(staleQueueIndex, 1); - await historyService.appendLog( - jobId, - 'USER_ACTION', - `Queue-Eintrag entfernt (Review-Neustart): ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` - ); + removedQueueActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; await this.emitQueueChanged(); } - await historyService.resetProcessLog(jobId); - const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); @@ -9280,44 +9860,91 @@ class PipelineService extends EventEmitter { if (resolvedReviewRawPath !== sourceJob.raw_path) { jobUpdatePayload.raw_path = resolvedReviewRawPath; } - await historyService.updateJob(jobId, jobUpdatePayload); + + const replacementJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'MEDIAINFO_CHECK', + detectedTitle: sourceJob.detected_title || sourceJob.title || null + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + disc_device: sourceJob.disc_device || null, + rip_successful: Number(sourceJob.rip_successful || 0), + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + ...jobUpdatePayload + }); + + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + + if (removedQueueActionLabel) { + await historyService.appendLog( + replacementJobId, + 'USER_ACTION', + `Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}` + ); + } if (shouldRealignEncodeInput) { await historyService.appendLog( - jobId, + replacementJobId, 'SYSTEM', `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${normalizedReviewInputPath}` ); } await historyService.appendLog( - jobId, + replacementJobId, 'USER_ACTION', `Review-Neustart aus RAW angefordert.${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.` ); + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'restart_review' + }); await this.setState('MEDIAINFO_CHECK', { - activeJobId: jobId, + activeJobId: replacementJobId, progress: 0, eta: null, statusText: 'Titel-/Spurprüfung wird neu gestartet...', context: { ...(this.snapshot.context || {}), - jobId, + jobId: replacementJobId, reviewConfirmed: false, mediaInfoReview: null } }); - this.runReviewForRawJob(jobId, resolvedReviewRawPath, { + this.runReviewForRawJob(replacementJobId, resolvedReviewRawPath, { mode: options?.mode || 'reencode', - sourceJobId: jobId, + sourceJobId: Number(jobId), forcePlaylistReselection, forceFreshAnalyze: true, previousEncodePlan }).catch((error) => { - logger.error('restartReviewFromRaw:background-failed', { jobId, error: errorToMeta(error) }); - this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('restartReviewFromRaw:background-failed', { jobId: replacementJobId, sourceJobId: jobId, error: errorToMeta(error) }); + this.failJob(replacementJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { logger.error('restartReviewFromRaw:background-failJob-failed', { - jobId, + jobId: replacementJobId, error: errorToMeta(failError) }); }); @@ -9327,7 +9954,9 @@ class PipelineService extends EventEmitter { restarted: true, started: true, stage: 'MEDIAINFO_CHECK', - jobId + sourceJobId: Number(jobId), + jobId: replacementJobId, + replacedSourceJob: true }; } @@ -9343,20 +9972,23 @@ class PipelineService extends EventEmitter { throw error; } - const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); - if (queuedIndex >= 0) { - const [removed] = this.queueEntries.splice(queuedIndex, 1); - await historyService.appendLog( - normalizedJobId, - 'USER_ACTION', - `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` - ); - await this.emitQueueChanged(); - return { - cancelled: true, - queuedOnly: true, - jobId: normalizedJobId - }; + const processHandle = this.activeProcesses.get(normalizedJobId) || null; + if (!processHandle) { + const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (queuedIndex >= 0) { + const [removed] = this.queueEntries.splice(queuedIndex, 1); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` + ); + await this.emitQueueChanged(); + return { + cancelled: true, + queuedOnly: true, + jobId: normalizedJobId + }; + } } const buildForcedCancelError = (message) => { @@ -9447,7 +10079,6 @@ class PipelineService extends EventEmitter { || '' ).trim().toUpperCase(); - const processHandle = this.activeProcesses.get(normalizedJobId) || null; if (!processHandle) { if (runningStatus === 'READY_TO_ENCODE') { // Kein laufender Prozess – Job direkt abbrechen @@ -9485,11 +10116,29 @@ class PipelineService extends EventEmitter { throw error; } + let removedQueuedActionLabel = null; + const staleQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (staleQueueIndex >= 0) { + const [removed] = this.queueEntries.splice(staleQueueIndex, 1); + removedQueuedActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; + await this.emitQueueChanged(); + try { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Veralteter Queue-Eintrag beim Abbruch entfernt: ${removedQueuedActionLabel}` + ); + } catch (_error) { + // keep cancel flow even if stale queue entry logging fails + } + } + logger.warn('cancel:requested', { state: this.snapshot.state, activeJobId: this.snapshot.activeJobId, requestedJobId: normalizedJobId, - pid: processHandle?.child?.pid || null + pid: processHandle?.child?.pid || null, + removedQueuedAction: removedQueuedActionLabel }); this.cancelRequestedByJob.add(normalizedJobId); processHandle.cancel(); @@ -9714,7 +10363,18 @@ class PipelineService extends EventEmitter { const title = job?.title || job?.detected_title || `Job #${jobId}`; const finalState = isCancelled ? 'CANCELLED' : 'ERROR'; logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) }); + const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); const encodePlan = this.safeParseJson(job?.encode_plan_json); + const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: normalizedStage.startsWith('CD_') ? 'cd' : null + }); + const isCdFailure = resolvedMediaProfile === 'cd' + || normalizedStage.startsWith('CD_') + || String(job?.status || '').trim().toUpperCase().startsWith('CD_') + || String(job?.last_state || '').trim().toUpperCase().startsWith('CD_') + || (Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0); const mode = String(encodePlan?.mode || '').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); const hasEncodableInput = isPreRipMode @@ -9776,6 +10436,61 @@ class PipelineService extends EventEmitter { 'SYSTEM', `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}` ); + const jobProgressContext = this.jobProgress.get(Number(jobId))?.context; + const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const fallbackCdArtist = Array.isArray(makemkvInfo?.tracks) + ? ( + makemkvInfo.tracks + .map((track) => String(track?.artist || '').trim()) + .find(Boolean) || null + ) + : null; + const resolvedCdMbId = String( + cdSelectedMetadata?.mbId + || cdSelectedMetadata?.musicBrainzId + || cdSelectedMetadata?.musicbrainzId + || cdSelectedMetadata?.mbid + || '' + ).trim() || null; + const resolvedCdCoverUrl = String( + cdSelectedMetadata?.coverUrl + || cdSelectedMetadata?.poster + || cdSelectedMetadata?.posterUrl + || job?.poster_url + || '' + ).trim() || null; + const resolvedSelectedMetadata = isCdFailure + ? { + title: cdSelectedMetadata?.title || job?.title || job?.detected_title || null, + artist: cdSelectedMetadata?.artist || fallbackCdArtist || null, + year: cdSelectedMetadata?.year ?? job?.year ?? null, + mbId: resolvedCdMbId, + coverUrl: resolvedCdCoverUrl, + imdbId: job?.imdb_id || null, + poster: job?.poster_url || resolvedCdCoverUrl || null + } + : { + title: job?.title || job?.detected_title || null, + year: job?.year || null, + imdbId: job?.imdb_id || null, + poster: job?.poster_url || null + }; + const resolvedTracks = isCdFailure + ? ( + Array.isArray(jobProgressContext?.tracks) && jobProgressContext.tracks.length > 0 + ? jobProgressContext.tracks + : (Array.isArray(makemkvInfo?.tracks) ? makemkvInfo.tracks : []) + ) + : []; + const resolvedCdRipConfig = isCdFailure + ? ( + jobProgressContext?.cdRipConfig && typeof jobProgressContext.cdRipConfig === 'object' + ? jobProgressContext.cdRipConfig + : (encodePlan && typeof encodePlan === 'object' ? encodePlan : null) + ) + : null; await this.setState(finalState, { activeJobId: jobId, @@ -9783,17 +10498,22 @@ class PipelineService extends EventEmitter { eta: null, statusText: message, context: { + ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), jobId, stage, error: message, rawPath: job?.raw_path || null, + outputPath: job?.output_path || null, + mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile, inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, - selectedMetadata: { - title: job?.title || job?.detected_title || null, - year: job?.year || null, - imdbId: job?.imdb_id || null, - poster: job?.poster_url || null - }, + selectedMetadata: resolvedSelectedMetadata, + ...(isCdFailure ? { + tracks: resolvedTracks, + cdRipConfig: resolvedCdRipConfig, + cdLive: jobProgressContext?.cdLive || null, + devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null, + cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null + } : {}), canRestartEncodeFromLastSettings: hasConfirmedPlan, canRestartReviewFromRaw: hasRawPath } @@ -9978,6 +10698,12 @@ class PipelineService extends EventEmitter { last_state: 'CD_READY_TO_RIP', makemkv_info_json: JSON.stringify(updatedCdInfo) }); + + // Bild in Cache laden (async, blockiert nicht) + if (coverUrl) { + thumbnailService.cacheJobThumbnail(jobId, coverUrl).catch(() => {}); + } + await historyService.appendLog( jobId, 'SYSTEM', @@ -10012,17 +10738,81 @@ class PipelineService extends EventEmitter { async startCdRip(jobId, ripConfig) { this.ensureNotBusy('startCdRip', jobId); + this.cancelRequestedByJob.delete(Number(jobId)); - const job = await historyService.getJobById(jobId); - if (!job) { + const sourceJob = await historyService.getJobById(jobId); + if (!sourceJob) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } - const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + let activeJobId = Number(jobId); + let activeJob = sourceJob; + const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase(); + const shouldReplaceSourceJob = sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'; + if (shouldReplaceSourceJob) { + const replacementJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'CD_READY_TO_RIP', + detectedTitle: sourceJob.detected_title || sourceJob.title || null + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('CD-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + error_message: null, + end_time: null, + output_path: null, + disc_device: sourceJob.disc_device || null, + raw_path: null, + rip_successful: 0, + makemkv_info_json: sourceJob.makemkv_info_json || null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + + await historyService.appendLog( + replacementJobId, + 'USER_ACTION', + `CD-Rip Neustart aus Job #${jobId}. Alter Job wurde durch neuen Job ersetzt.` + ); + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'cd_restart_rip' + }); + + activeJobId = replacementJobId; + activeJob = await historyService.getJobById(replacementJobId); + this.cancelRequestedByJob.delete(replacementJobId); + if (!activeJob) { + throw new Error(`CD-Neustart fehlgeschlagen: neuer Job #${replacementJobId} konnte nicht geladen werden.`); + } + } + + const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; const device = this.detectedDisc || this.snapshot.context?.device; - const devicePath = String(device?.path || job.disc_device || '').trim(); + const devicePath = String(device?.path || activeJob.disc_device || '').trim(); if (!devicePath) { const error = new Error('Kein CD-Laufwerk bekannt.'); @@ -10073,7 +10863,7 @@ class PipelineService extends EventEmitter { ...selectedMeta, title: normalizeCdTrackText(incomingMeta?.title) || normalizeCdTrackText(selectedMeta?.title) - || normalizeCdTrackText(job?.title) + || normalizeCdTrackText(activeJob?.title) || normalizeCdTrackText(cdInfo?.detectedTitle) || 'Audio CD', artist: normalizeCdTrackText(incomingMeta?.artist) @@ -10081,7 +10871,7 @@ class PipelineService extends EventEmitter { || null, year: normalizeOptionalYear(incomingMeta?.year) ?? normalizeOptionalYear(selectedMeta?.year) - ?? normalizeOptionalYear(job?.year) + ?? normalizeOptionalYear(activeJob?.year) ?? null }; const mergedTracks = tocTracks.map((track) => { @@ -10109,26 +10899,114 @@ class PipelineService extends EventEmitter { const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0 ? selectedTrackPositions : mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position); + const selectedPreEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPreEncodeScriptIds || []); + const selectedPostEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPostEncodeScriptIds || []); + const selectedPreEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPreEncodeChainIds || []); + const selectedPostEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPostEncodeChainIds || []); + + const [ + selectedPreEncodeScripts, + selectedPostEncodeScripts, + selectedPreEncodeChains, + selectedPostEncodeChains + ] = await Promise.all([ + scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }), + scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }), + scriptChainService.getChainsByIds(selectedPreEncodeChainIds), + scriptChainService.getChainsByIds(selectedPostEncodeChainIds) + ]); + + const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => { + const resolved = Array.isArray(resolvedChains) ? resolvedChains : []; + const resolvedSet = new Set( + resolved + .map((chain) => Number(chain?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + .map((id) => Math.trunc(id)) + ); + const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id))); + if (missing.length === 0) { + return; + } + const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`); + error.statusCode = 400; + error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }]; + throw error; + }; + ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds'); + ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds'); + + const toScriptDescriptor = (script) => { + const id = Number(script?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(script?.name || '').trim() || `Skript #${normalizedId}`; + return { id: normalizedId, name }; + }; + const toChainDescriptor = (chain) => { + const id = Number(chain?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`; + return { id: normalizedId, name }; + }; const settings = await settingsService.getEffectiveSettingsMap('cd'); const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; const cdOutputTemplate = String( settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; - const cdBaseDir = String(settings.raw_dir_cd || '').trim() || 'data/output/cd'; + const cdBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; const cdOutputOwner = String(settings.raw_dir_owner || '').trim(); - const jobDir = `CD_Job${jobId}_${Date.now()}`; - const rawWavDir = path.join(cdBaseDir, '.tmp', jobDir, 'wav'); - const cdTempJobDir = path.dirname(rawWavDir); + const cdMetadataBase = buildRawMetadataBase({ + title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + year: effectiveSelectedMeta?.year || null + }, activeJobId); + const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); + const rawJobDir = path.join(cdBaseDir, rawDirName); + const rawWavDir = rawJobDir; const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate); ensureDir(cdBaseDir); - ensureDir(cdTempJobDir); + ensureDir(rawJobDir); ensureDir(outputDir); - chownRecursive(cdTempJobDir, cdOutputOwner); + chownRecursive(rawJobDir, cdOutputOwner); chownRecursive(outputDir, cdOutputOwner); const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath} ${previewTrackPos} ${previewWavPath}`; + const cdLiveTrackRows = buildCdLiveTrackRows( + effectiveSelectedTrackPositions, + mergedTracks, + effectiveSelectedMeta?.artist + ); + const initialCdLive = buildCdLiveProgressSnapshot({ + trackRows: cdLiveTrackRows, + phase: 'rip', + trackIndex: cdLiveTrackRows.length > 0 ? 1 : 0, + trackTotal: cdLiveTrackRows.length, + trackPosition: cdLiveTrackRows[0]?.position || null, + ripCompletedCount: 0, + encodeCompletedCount: 0 + }); + const cdEncodePlan = { + format, + formatOptions, + selectedTracks: effectiveSelectedTrackPositions, + tracks: mergedTracks, + outputTemplate: cdOutputTemplate, + preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), + postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean), + preEncodeChainIds: selectedPreEncodeChainIds, + postEncodeChainIds: selectedPostEncodeChainIds, + preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean), + postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean) + }; const updatedCdInfo = { ...cdInfo, @@ -10136,56 +11014,70 @@ class PipelineService extends EventEmitter { selectedMetadata: effectiveSelectedMeta }; - await historyService.updateJob(jobId, { + await historyService.updateJob(activeJobId, { title: effectiveSelectedMeta?.title || null, year: normalizeOptionalYear(effectiveSelectedMeta?.year), status: 'CD_RIPPING', last_state: 'CD_RIPPING', error_message: null, - raw_path: null, + raw_path: rawJobDir, output_path: outputDir, + handbrake_info_json: null, + mediainfo_info_json: null, makemkv_info_json: JSON.stringify(updatedCdInfo), - encode_plan_json: JSON.stringify({ - format, - formatOptions, - selectedTracks: effectiveSelectedTrackPositions, - tracks: mergedTracks, - outputTemplate: cdOutputTemplate - }) + encode_plan_json: JSON.stringify(cdEncodePlan) }); await this.setState('CD_RIPPING', { - activeJobId: jobId, + activeJobId, progress: 0, eta: null, statusText: 'CD wird gerippt …', context: { ...(this.snapshot.context || {}), - jobId, + jobId: activeJobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: effectiveSelectedMeta, devicePath, cdparanoiaCmd, rawWavDir, + outputPath: outputDir, outputTemplate: cdOutputTemplate, + cdRipConfig: cdEncodePlan, + cdLive: initialCdLive, cdparanoiaCommandPreview } }); - logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: effectiveSelectedTrackPositions.length }); + logger.info('cd:rip:start', { jobId: activeJobId, devicePath, format, trackCount: effectiveSelectedTrackPositions.length }); await historyService.appendLog( - jobId, + activeJobId, 'SYSTEM', `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` ); + if ( + selectedPreEncodeScripts.length > 0 + || selectedPreEncodeChains.length > 0 + || selectedPostEncodeScripts.length > 0 + || selectedPostEncodeChains.length > 0 + ) { + await historyService.appendLog( + activeJobId, + 'SYSTEM', + `CD Skript-Auswahl: Pre-Skripte=${selectedPreEncodeScripts.length}, Pre-Ketten=${selectedPreEncodeChains.length}, ` + + `Post-Skripte=${selectedPostEncodeScripts.length}, Post-Ketten=${selectedPostEncodeChains.length}.` + ); + } // Run asynchronously so the HTTP response returns immediately this._runCdRip({ - jobId, + jobId: activeJobId, devicePath, cdparanoiaCmd, rawWavDir, + rawBaseDir: cdBaseDir, + cdMetadataBase, outputDir, format, formatOptions, @@ -10193,12 +11085,18 @@ class PipelineService extends EventEmitter { outputOwner: cdOutputOwner, selectedTrackPositions: effectiveSelectedTrackPositions, tocTracks: mergedTracks, - selectedMeta: effectiveSelectedMeta + selectedMeta: effectiveSelectedMeta, + encodePlan: cdEncodePlan }).catch((error) => { - logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) }); + logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) }); }); - return { jobId, started: true }; + return { + jobId: activeJobId, + sourceJobId: shouldReplaceSourceJob ? Number(jobId) : null, + replacedSourceJob: shouldReplaceSourceJob, + started: true + }; } async _runCdRip({ @@ -10206,6 +11104,8 @@ class PipelineService extends EventEmitter { devicePath, cdparanoiaCmd, rawWavDir, + rawBaseDir, + cdMetadataBase, outputDir, format, formatOptions, @@ -10213,7 +11113,8 @@ class PipelineService extends EventEmitter { outputOwner, selectedTrackPositions, tocTracks, - selectedMeta + selectedMeta, + encodePlan = null }) { const processKey = Number(jobId); let currentProcessHandle = null; @@ -10244,6 +11145,59 @@ class PipelineService extends EventEmitter { this.syncPrimaryActiveProcess(); try { + const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []); + const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []); + const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []); + const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []); + let preEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + let postEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + const selectedTrackOrder = normalizeCdTrackPositionList(selectedTrackPositions); + const liveTrackRows = buildCdLiveTrackRows(selectedTrackOrder, tocTracks, selectedMeta?.artist); + const effectiveTrackTotal = liveTrackRows.length; + let ripCompletedCount = 0; + let encodeCompletedCount = 0; + let currentPhase = 'rip'; + let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; + let currentTrackPosition = liveTrackRows[0]?.position || null; + const buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({ + trackRows: liveTrackRows, + phase: currentPhase, + trackIndex: currentTrackIndex, + trackTotal: effectiveTrackTotal, + trackPosition: currentTrackPosition, + ripCompletedCount, + encodeCompletedCount, + failedTrackPosition + }); + if (preScriptIds.length > 0 || preChainIds.length > 0) { + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten werden ausgeführt...'); + preEncodeScriptsSummary = await this.runPreEncodeScripts(jobId, normalizedEncodePlan, { + mode: 'cd_rip', + jobId, + jobTitle: selectedMeta?.title || `Job #${jobId}`, + inputPath: devicePath || null, + outputPath: outputDir || null, + rawPath: rawWavDir || null, + mediaProfile: 'cd', + pipelineStage: 'CD_RIPPING' + }); + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten abgeschlossen.'); + } let encodeStateApplied = false; let lastProgressPercent = 0; const bindProcessHandle = (handle) => { @@ -10272,9 +11226,15 @@ class PipelineService extends EventEmitter { meta: selectedMeta, onProcessHandle: bindProcessHandle, isCancelled: () => this.cancelRequestedByJob.has(processKey), - onProgress: async ({ phase, percent, trackIndex, trackTotal }) => { + onProgress: async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => { const normalizedPhase = phase === 'encode' ? 'encode' : 'rip'; const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING'; + const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || effectiveTrackTotal; + const normalizedTrackIndex = normalizePositiveInteger(trackIndex) + || currentTrackIndex + || (normalizedTrackTotal > 0 ? 1 : 0); + const normalizedTrackPosition = normalizePositiveInteger(trackPosition) || currentTrackPosition || null; + const normalizedTrackEvent = String(trackEvent || '').trim().toLowerCase(); let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); if (normalizedPhase === 'rip') { clampedPercent = Math.min(clampedPercent, 50); @@ -10284,8 +11244,36 @@ class PipelineService extends EventEmitter { if (clampedPercent < lastProgressPercent) { clampedPercent = lastProgressPercent; } + clampedPercent = Number(clampedPercent.toFixed(2)); lastProgressPercent = clampedPercent; + if (normalizedPhase === 'rip') { + currentPhase = 'rip'; + currentTrackIndex = normalizedTrackIndex; + currentTrackPosition = normalizedTrackPosition; + if (normalizedTrackEvent === 'complete') { + ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackIndex); + if (ripCompletedCount >= normalizedTrackTotal) { + currentTrackPosition = null; + } + } else { + ripCompletedCount = Math.max(ripCompletedCount, Math.max(0, normalizedTrackIndex - 1)); + } + } else { + currentPhase = 'encode'; + ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackTotal); + currentTrackIndex = normalizedTrackIndex; + currentTrackPosition = normalizedTrackPosition; + if (normalizedTrackEvent === 'complete') { + encodeCompletedCount = Math.max(encodeCompletedCount, normalizedTrackIndex); + if (encodeCompletedCount >= normalizedTrackTotal) { + currentTrackPosition = null; + } + } else { + encodeCompletedCount = Math.max(encodeCompletedCount, Math.max(0, normalizedTrackIndex - 1)); + } + } + if (normalizedPhase === 'encode' && !encodeStateApplied) { encodeStateApplied = true; await historyService.updateJob(jobId, { @@ -10301,7 +11289,11 @@ class PipelineService extends EventEmitter { ? `CD wird gerippt …${detail}` : `Tracks werden encodiert …${detail}`; - await this.updateProgress(stage, clampedPercent, null, statusText, processKey); + await this.updateProgress(stage, clampedPercent, null, statusText, processKey, { + contextPatch: { + cdLive: buildLiveContext(null) + } + }); }, onLog: async (level, msg) => { await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); @@ -10310,26 +11302,95 @@ class PipelineService extends EventEmitter { }); settleLifecycle(); + if (postScriptIds.length > 0 || postChainIds.length > 0) { + await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...'); + try { + postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, { + mode: 'cd_rip', + jobId, + jobTitle: selectedMeta?.title || `Job #${jobId}`, + inputPath: devicePath || null, + outputPath: outputDir || null, + rawPath: rawWavDir || null, + mediaProfile: 'cd', + pipelineStage: 'CD_ENCODING' + }); + } catch (error) { + logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ` + + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.` + ); + } + + // RAW-Verzeichnis von Incomplete_ → finalen Namen umbenennen + let activeRawDir = rawWavDir; + try { + const completedRawDirName = buildRawDirName(cdMetadataBase, jobId, { state: RAW_FOLDER_STATES.COMPLETE }); + const completedRawDir = path.join(rawBaseDir, completedRawDirName); + if (activeRawDir !== completedRawDir && fs.existsSync(activeRawDir) && !fs.existsSync(completedRawDir)) { + fs.renameSync(activeRawDir, completedRawDir); + activeRawDir = completedRawDir; + } + } catch (_renameError) { + // ignore – raw dir bleibt unter Incomplete_-Name zugänglich + } + // Success await historyService.updateJob(jobId, { status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), rip_successful: 1, - output_path: outputDir + raw_path: activeRawDir, + output_path: outputDir, + handbrake_info_json: JSON.stringify({ + mode: 'cd_rip', + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }) }); + + // Thumbnail aus Cache in persistenten Ordner verschieben + const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (cdPromotedUrl) { + await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); + } + + chownRecursive(activeRawDir, outputOwner); chownRecursive(outputDir, outputOwner); await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); + const finishedStatusText = postEncodeScriptsSummary.failed > 0 + ? `CD-Rip abgeschlossen (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` + : 'CD-Rip abgeschlossen'; + currentPhase = 'encode'; + ripCompletedCount = effectiveTrackTotal; + encodeCompletedCount = effectiveTrackTotal; + currentTrackIndex = effectiveTrackTotal; + currentTrackPosition = null; + const finishedCdLive = buildLiveContext(null); await this.setState('FINISHED', { activeJobId: jobId, progress: 100, eta: null, - statusText: 'CD-Rip abgeschlossen', + statusText: finishedStatusText, context: { jobId, mediaProfile: 'cd', + tracks: tocTracks, outputDir, + outputPath: outputDir, + cdRipConfig: normalizedEncodePlan, + cdLive: finishedCdLive, selectedMetadata: selectedMeta } }); @@ -10340,17 +11401,22 @@ class PipelineService extends EventEmitter { }); } catch (error) { settleLifecycle(); + const failedCdLive = buildLiveContext(currentTrackPosition || null); + await this.updateProgress( + this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', + this.snapshot.progress, + null, + this.snapshot.statusText, + processKey, + { + contextPatch: { + cdLive: failedCdLive + } + } + ); logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error); } finally { - try { - const cdTempJobDir = path.dirname(String(rawWavDir || '')); - if (cdTempJobDir && cdTempJobDir !== '.' && cdTempJobDir !== path.sep) { - fs.rmSync(cdTempJobDir, { recursive: true, force: true }); - } - } catch (_cleanupError) { - // ignore temp cleanup issues - } this.activeProcesses.delete(processKey); this.syncPrimaryActiveProcess(); } diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js index 616b1a0..91908cd 100644 --- a/backend/src/services/scriptService.js +++ b/backend/src/services/scriptService.js @@ -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' diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 63ddb2a..f750bbc 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -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; diff --git a/backend/src/services/thumbnailService.js b/backend/src/services/thumbnailService.js new file mode 100644 index 0000000..c7854ca --- /dev/null +++ b/backend/src/services/thumbnailService.js @@ -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} 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 +}; diff --git a/db/schema.sql b/db/schema.sql index 3af1dc0..8c82408 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -24,6 +24,7 @@ CREATE TABLE settings_values ( CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER, title TEXT, year INTEGER, imdb_id TEXT, @@ -47,11 +48,29 @@ CREATE TABLE jobs ( encode_input_path TEXT, encode_review_confirmed INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL ); CREATE INDEX idx_jobs_status ON jobs(status); CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); +CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id); + +CREATE TABLE job_lineage_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + source_job_id INTEGER, + media_type TEXT, + raw_path TEXT, + output_path TEXT, + reason TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id); +CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id); CREATE TABLE scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -151,29 +170,21 @@ CREATE INDEX idx_user_presets_media_type ON user_presets(media_type); -- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); +VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); +VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_other_owner', 'Pfade', 'Eigentümer Raw-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1035); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other_owner', NULL); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); +VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); +VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_other_owner', 'Pfade', 'Eigentümer Film-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other_owner', NULL); - -- Laufwerk INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10); @@ -187,43 +198,31 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('disc_auto_detection_enabled', 'Laufwerk', 'Automatische Disk-Erkennung', 'boolean', 1, 'Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt.', 'true', '[]', '{}', 35); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_auto_detection_enabled', 'true'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000'); -- Pfade INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir', 'Pfade', 'Raw Ausgabeordner', 'path', 1, 'Zwischenablage für MakeMKV Rip.', 'data/output/raw', '[]', '{"minLength":1}', 100); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir', 'data/output/raw'); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_bluray', 'Pfade', 'Raw Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 101); +VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_dvd', 'Pfade', 'Raw Ausgabeordner (DVD)', 'path', 0, 'Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 102); +VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_other', 'Pfade', 'Raw Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Sonstiges. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 103); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other', NULL); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir', 'Pfade', 'Film Ausgabeordner', 'path', 1, 'Finale HandBrake Ausgabe.', 'data/output/movies', '[]', '{"minLength":1}', 110); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir', 'data/output/movies'); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_bluray', 'Pfade', 'Film Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 111); +VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_dvd', 'Pfade', 'Film Ausgabeordner (DVD)', 'path', 0, 'Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 112); +VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('movie_dir_other', 'Pfade', 'Film Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Sonstiges. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 113); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other', NULL); - INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs'); @@ -263,9 +262,28 @@ VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Parallele Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.', '1', '[]', '{"min":1,"max":12}', 225); +VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_parallel_cd_encodes', 'Tools', 'Max. parallele Audio CD Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit.', '2', '[]', '{"min":1,"max":12}', 226); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_cd_encodes', '2'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_total_encodes', 'Tools', 'Max. Encodes gesamt (medienunabhängig)', 'number', 1, 'Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits.', '3', '[]', '{"min":1,"max":24}', 227); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_total_encodes', '3'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_cd_bypasses_queue', 'Tools', 'Audio CDs: Queue-Reihenfolge überspringen', 'boolean', 1, 'Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin.', 'false', '[]', '{}', 228); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_cd_bypasses_queue', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('script_test_timeout_ms', 'Tools', 'Script-Test Timeout (ms)', 'number', 1, 'Timeout fuer Script-Tests in den Settings. 0 = kein Timeout.', '0', '[]', '{"min":0,"max":86400000}', 229); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('script_test_timeout_ms', '0'); + +-- Migration: Label für bestehende Installationen aktualisieren +UPDATE settings_schema SET label = 'Max. parallele Film/Video Encodes', description = 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.' WHERE key = 'pipeline_max_parallel_jobs' AND label = 'Parallele Jobs'; + -- Tools – Blu-ray INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300); @@ -296,12 +314,8 @@ VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Datei INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('filename_template_bluray', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (Blu-ray).', '${title} (${year})', '[]', '{"minLength":1}', 335); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_bluray', '${title} (${year})'); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('output_folder_template_bluray', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (Blu-ray).', NULL, '[]', '{}', 340); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_bluray', NULL); +VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); -- Tools – DVD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) @@ -310,7 +324,7 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 505); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'mkv'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510); @@ -333,12 +347,8 @@ VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiend INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('filename_template_dvd', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (DVD).', '${title} (${year})', '[]', '{"minLength":1}', 535); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_dvd', '${title} (${year})'); - -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL); +VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})'); -- Tools – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) @@ -348,11 +358,11 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ( 'cd_output_template', - 'Tools', + 'Pfade', 'CD Output Template', 'string', 1, - 'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt.', + 'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '{artist} - {album} ({year})/{trackNr} {artist} - {title}', '[]', '{"minLength":1}', @@ -363,11 +373,11 @@ VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - -- Pfade – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_cd', 'Pfade', 'CD Ausgabeordner', 'path', 0, 'Optionaler Ausgabeordner für geripppte CD-Dateien. Leer = Fallback auf "Raw Ausgabeordner".', '/opt/ripster/backend/data/output/cd', '[]', '{}', 104); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', '/opt/ripster/backend/data/output/cd'); +VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); +VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); -- Metadaten @@ -384,6 +394,10 @@ VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true'); -- Benachrichtigungen +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false'); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c16f70..5ce7702 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,17 +34,33 @@ function App() { if (message.type === 'PIPELINE_PROGRESS') { const payload = message.payload; const progressJobId = payload?.activeJobId; + const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object' + ? payload.contextPatch + : null; setPipeline((prev) => { const next = { ...prev }; // Update per-job progress map so concurrent jobs don't overwrite each other. if (progressJobId != null) { + const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; + const mergedJobContext = contextPatch + ? { + ...(previousJobProgress?.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + ...contextPatch + } + : (previousJobProgress?.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : undefined); next.jobProgress = { ...(prev?.jobProgress || {}), [progressJobId]: { + ...previousJobProgress, state: payload.state, progress: payload.progress, eta: payload.eta, - statusText: payload.statusText + statusText: payload.statusText, + ...(mergedJobContext !== undefined ? { context: mergedJobContext } : {}) } }; } @@ -54,6 +70,12 @@ function App() { next.progress = payload.progress ?? prev?.progress; next.eta = payload.eta ?? prev?.eta; next.statusText = payload.statusText ?? prev?.statusText; + if (contextPatch) { + next.context = { + ...(prev?.context && typeof prev.context === 'object' ? prev.context : {}), + ...contextPatch + }; + } } return next; }); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 70d2c23..3e37302 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -116,6 +116,12 @@ export const api = { forceRefresh: options.forceRefresh }); }, + getEffectivePaths(options = {}) { + return requestCachedGet('/settings/effective-paths', { + ttlMs: 30 * 1000, + forceRefresh: options.forceRefresh + }); + }, getHandBrakePresets(options = {}) { return requestCachedGet('/settings/handbrake-presets', { ttlMs: 10 * 60 * 1000, @@ -437,10 +443,17 @@ export const api = { afterMutationInvalidate(['/history']); return result; }, - async deleteJobEntry(jobId, target = 'none') { + getJobDeletePreview(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const query = new URLSearchParams(); + query.set('includeRelated', includeRelated ? '1' : '0'); + return request(`/history/${jobId}/delete-preview?${query.toString()}`); + }, + async deleteJobEntry(jobId, target = 'none', options = {}) { + const includeRelated = Boolean(options?.includeRelated); const result = await request(`/history/${jobId}/delete`, { method: 'POST', - body: JSON.stringify({ target }) + body: JSON.stringify({ target, includeRelated }) }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index 2d82ed1..e5a8adf 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -8,6 +8,7 @@ import { InputText } from 'primereact/inputtext'; import { InputNumber } from 'primereact/inputnumber'; import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; import { api } from '../api/client'; +import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; function isFieldVisible(field, values) { if (!field.showWhen) { @@ -54,113 +55,6 @@ function FormatField({ field, value, onChange }) { return null; } -function quoteShellArg(value) { - const text = String(value || ''); - if (!text) { - return "''"; - } - if (/^[a-zA-Z0-9_./:-]+$/.test(text)) { - return text; - } - return `'${text.replace(/'/g, "'\\''")}'`; -} - -function buildCommandLine(cmd, args = []) { - const normalizedArgs = Array.isArray(args) ? args : []; - return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' '); -} - -function buildEncodeCommandPreview({ - format, - formatOptions, - wavFile, - outFile, - trackTitle, - trackArtist, - albumTitle, - year, - trackNo -}) { - const normalizedFormat = String(format || '').trim().toLowerCase(); - const title = String(trackTitle || `Track ${trackNo || 1}`).trim() || `Track ${trackNo || 1}`; - const artist = String(trackArtist || '').trim(); - const album = String(albumTitle || '').trim(); - const releaseYear = year == null ? '' : String(year).trim(); - const number = String(trackNo || 1); - - if (normalizedFormat === 'wav') { - return buildCommandLine('mv', [wavFile, outFile]); - } - - if (normalizedFormat === 'flac') { - const level = Math.max(0, Math.min(8, Number(formatOptions?.flacCompression ?? 5))); - return buildCommandLine('flac', [ - `--compression-level-${level}`, - '--tag', `TITLE=${title}`, - '--tag', `ARTIST=${artist}`, - '--tag', `ALBUM=${album}`, - '--tag', `DATE=${releaseYear}`, - '--tag', `TRACKNUMBER=${number}`, - wavFile, - '-o', outFile - ]); - } - - if (normalizedFormat === 'mp3') { - const mode = String(formatOptions?.mp3Mode || 'cbr').trim().toLowerCase(); - const args = ['--id3v2-only', '--noreplaygain']; - if (mode === 'vbr') { - const quality = Math.max(0, Math.min(9, Number(formatOptions?.mp3Quality ?? 4))); - args.push('-V', String(quality)); - } else { - const bitrate = Number(formatOptions?.mp3Bitrate ?? 192); - args.push('-b', String(bitrate)); - } - args.push( - '--tt', title, - '--ta', artist, - '--tl', album, - '--ty', releaseYear, - '--tn', number, - wavFile, - outFile - ); - return buildCommandLine('lame', args); - } - - if (normalizedFormat === 'opus') { - const bitrate = Math.max(32, Math.min(512, Number(formatOptions?.opusBitrate ?? 160))); - const complexity = Math.max(0, Math.min(10, Number(formatOptions?.opusComplexity ?? 10))); - return buildCommandLine('opusenc', [ - '--bitrate', String(bitrate), - '--comp', String(complexity), - '--title', title, - '--artist', artist, - '--album', album, - '--date', releaseYear, - '--tracknumber', number, - wavFile, - outFile - ]); - } - - if (normalizedFormat === 'ogg') { - const quality = Math.max(-1, Math.min(10, Number(formatOptions?.oggQuality ?? 6))); - return buildCommandLine('oggenc', [ - '-q', String(quality), - '-t', title, - '-a', artist, - '-l', album, - '-d', releaseYear, - '-N', number, - '-o', outFile, - wavFile - ]); - } - - return ''; -} - function normalizePosition(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -195,23 +89,159 @@ function formatTrackDuration(track) { return `${min}:${String(sec).padStart(2, '0')}`; } +function formatTotalDuration(totalSec) { + const parsed = Number(totalSec); + if (!Number.isFinite(parsed) || parsed <= 0) { + return '-'; + } + const rounded = Math.max(0, Math.trunc(parsed)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function formatProgressLabel(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return '0%'; + } + const clamped = Math.max(0, Math.min(100, parsed)); + const rounded = Math.round(clamped * 10) / 10; + if (Number.isInteger(rounded)) { + return `${rounded}%`; + } + return `${rounded.toFixed(1)}%`; +} + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') { + return 'done'; + } + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') { + return 'in_progress'; + } + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') { + return 'error'; + } + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') { + return { label: 'Fertig', severity: 'success' }; + } + if (normalized === 'in_progress') { + return { label: 'Läuft', severity: 'info' }; + } + if (normalized === 'error') { + return { label: 'Fehler', severity: 'danger' }; + } + return { label: 'Offen', severity: 'secondary' }; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeChainId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') { + return null; + } + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) { + return null; + } + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) { + return fromExplicit; + } + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + +function describeEncodeItem(item, scriptById, chainById) { + if (!item || typeof item !== 'object') { + return '-'; + } + if (item.type === 'chain') { + const chain = chainById.get(normalizeChainId(item.id)); + return chain?.name || `Kette #${item.id}`; + } + const script = scriptById.get(normalizeScriptId(item.id)); + return script?.name || `Skript #${item.id}`; +} + export default function CdRipConfigPanel({ pipeline, onStart, onCancel, + onRetry, + onOpenMetadata, busy }) { const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; const tracks = Array.isArray(context.tracks) ? context.tracks : []; const selectedMeta = context.selectedMetadata || {}; const state = String(pipeline?.state || '').trim().toUpperCase(); + const jobId = normalizePosition(context?.jobId); const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING'; const isFinished = state === 'FINISHED'; + const isTerminalFailure = state === 'CANCELLED' || state === 'ERROR'; const [format, setFormat] = useState('flac'); const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac')); - const [settingsCdparanoiaCmd, setSettingsCdparanoiaCmd] = useState(''); // Track selection: position → boolean const [selectedTracks, setSelectedTracks] = useState(() => { @@ -247,11 +277,45 @@ export default function CdRipConfigPanel({ artist: normalizeTrackText(selectedMeta?.artist) || '', year: normalizeYear(selectedMeta?.year) })); + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preRipItems, setPreRipItems] = useState([]); + const [postRipItems, setPostRipItems] = useState([]); + const cdRipConfig = context?.cdRipConfig && typeof context.cdRipConfig === 'object' ? context.cdRipConfig : {}; + const scriptById = new Map( + (Array.isArray(scriptCatalog) ? scriptCatalog : []) + .map((item) => [normalizeScriptId(item?.id), item]) + .filter(([id]) => id !== null) + ); + const chainById = new Map( + (Array.isArray(chainCatalog) ? chainCatalog : []) + .map((item) => [normalizeChainId(item?.id), item]) + .filter(([id]) => id !== null) + ); + const cdRipConfigKey = JSON.stringify({ + preEncodeScriptIds: normalizeIdList(cdRipConfig?.preEncodeScriptIds, 'script'), + postEncodeScriptIds: normalizeIdList(cdRipConfig?.postEncodeScriptIds, 'script'), + preEncodeChainIds: normalizeIdList(cdRipConfig?.preEncodeChainIds, 'chain'), + postEncodeChainIds: normalizeIdList(cdRipConfig?.postEncodeChainIds, 'chain') + }); useEffect(() => { setFormatOptions(getDefaultFormatOptions(format)); }, [format]); + useEffect(() => { + const configuredFormat = String(cdRipConfig?.format || '').trim().toLowerCase(); + if (!configuredFormat || !CD_FORMAT_SCHEMAS[configuredFormat]) { + return; + } + setFormat(configuredFormat); + setFormatOptions((prev) => ({ + ...getDefaultFormatOptions(configuredFormat), + ...(prev && typeof prev === 'object' ? prev : {}), + ...(cdRipConfig?.formatOptions && typeof cdRipConfig.formatOptions === 'object' ? cdRipConfig.formatOptions : {}) + })); + }, [context?.jobId, cdRipConfig?.format, JSON.stringify(cdRipConfig?.formatOptions || {})]); + useEffect(() => { setMetaFields({ title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '', @@ -260,30 +324,6 @@ export default function CdRipConfigPanel({ }); }, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]); - useEffect(() => { - let cancelled = false; - const refreshSettings = async () => { - try { - const response = await api.getSettings({ forceRefresh: true }); - if (cancelled) { - return; - } - const value = String(response?.settings?.cdparanoia_command || '').trim(); - setSettingsCdparanoiaCmd(value); - } catch (_error) { - if (!cancelled) { - setSettingsCdparanoiaCmd(''); - } - } - }; - refreshSettings(); - const intervalId = setInterval(refreshSettings, 5000); - return () => { - cancelled = true; - clearInterval(intervalId); - }; - }, [context?.jobId]); - useEffect(() => { setSelectedTracks((prev) => { const next = {}; @@ -323,6 +363,40 @@ export default function CdRipConfigPanel({ }); }, [tracks, selectedMeta?.artist]); + useEffect(() => { + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) { + return; + } + const scripts = scriptsResponse.status === 'fulfilled' + ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) + : []; + const chains = chainsResponse.status === 'fulfilled' + ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) + : []; + setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name }))); + setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); + } catch (_error) { + if (!cancelled) { + setScriptCatalog([]); + setChainCatalog([]); + } + } + }; + void loadCatalog(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setPreRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'pre')); + setPostRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'post')); + }, [context?.jobId, cdRipConfigKey]); + const handleFormatOptionChange = (key, value) => { setFormatOptions((prev) => ({ ...prev, [key]: value })); }; @@ -367,13 +441,99 @@ export default function CdRipConfigPanel({ })); }; + const moveEncodeItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) { + return list; + } + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addEncodeItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) { + return current; + } + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeEncodeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) { + return; + } + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) { + return false; + } + if (item?.type !== normalizedType) { + return false; + } + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) { + return current; + } + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeEncodeItem = (phase, index) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + const handleStart = () => { const albumTitle = normalizeTrackText(metaFields?.title) || normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || 'Audio CD'; + const fallbackArtistFromTracks = tracks + .map((track) => normalizeTrackText(track?.artist)) + .find(Boolean) || null; const albumArtist = normalizeTrackText(metaFields?.artist) || normalizeTrackText(selectedMeta?.artist) + || fallbackArtistFromTracks || null; const albumYear = normalizeYear(metaFields?.year); @@ -405,11 +565,32 @@ export default function CdRipConfigPanel({ return; } + const selectedPreEncodeScriptIds = normalizeIdList( + preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id), + 'script' + ); + const selectedPostEncodeScriptIds = normalizeIdList( + postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id), + 'script' + ); + const selectedPreEncodeChainIds = normalizeIdList( + preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id), + 'chain' + ); + const selectedPostEncodeChainIds = normalizeIdList( + postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id), + 'chain' + ); + onStart && onStart({ format, formatOptions, selectedTracks: selected, tracks: normalizedTracks, + selectedPreEncodeScriptIds, + selectedPostEncodeScriptIds, + selectedPreEncodeChainIds, + selectedPostEncodeChainIds, metadata: { title: albumTitle, artist: albumArtist, @@ -425,89 +606,285 @@ export default function CdRipConfigPanel({ const position = normalizePosition(t?.position); return position ? selectedTracks[position] !== false : false; }).length; - const firstSelectedTrack = tracks.find((t) => { - const position = normalizePosition(t?.position); - return position ? selectedTracks[position] !== false : false; - }) || null; - const devicePath = String(context?.devicePath || context?.device?.path || '').trim(); - const cdparanoiaCmd = settingsCdparanoiaCmd - || String(context?.cdparanoiaCmd || '').trim() - || 'cdparanoia'; - const rawWavDir = String(context?.rawWavDir || '').trim(); - const commandTrackNumber = firstSelectedTrack ? Math.trunc(Number(firstSelectedTrack.position)) : null; - const commandWavTarget = commandTrackNumber - ? ( - rawWavDir - ? `${rawWavDir}/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav` - : `/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav` - ) - : '/trackNN.cdda.wav'; - const cdparanoiaCommandPreview = [ - quoteShellArg(cdparanoiaCmd), - '-d', - quoteShellArg(devicePath || ''), - String(commandTrackNumber || ''), - quoteShellArg(commandWavTarget) - ].join(' '); - const commandTrackNo = commandTrackNumber || 1; - const commandTrackFields = trackFields[commandTrackNo] || {}; - const commandTrackTitle = normalizeTrackText(commandTrackFields.title) - || normalizeTrackText(firstSelectedTrack?.title) - || `Track ${commandTrackNo}`; - const commandTrackArtist = normalizeTrackText(commandTrackFields.artist) - || normalizeTrackText(firstSelectedTrack?.artist) - || normalizeTrackText(metaFields?.artist) - || normalizeTrackText(selectedMeta?.artist) - || 'Unknown Artist'; - const commandAlbumTitle = normalizeTrackText(metaFields?.title) - || normalizeTrackText(selectedMeta?.title) - || normalizeTrackText(context?.detectedTitle) - || 'Audio CD'; - const commandYear = normalizeYear(metaFields?.year) ?? normalizeYear(selectedMeta?.year); - const commandOutputFile = `/track${String(commandTrackNo).padStart(2, '0')}.${format}`; - const encodeCommandPreview = buildEncodeCommandPreview({ - format, - formatOptions, - wavFile: commandWavTarget, - outFile: commandOutputFile, - trackTitle: commandTrackTitle, - trackArtist: commandTrackArtist, - albumTitle: commandAlbumTitle, - year: commandYear, - trackNo: commandTrackNo - }); const progress = Number(pipeline?.progress ?? 0); const clampedProgress = Math.max(0, Math.min(100, progress)); + const roundedProgress = Math.round(clampedProgress * 10) / 10; const eta = String(pipeline?.eta || '').trim(); const statusText = String(pipeline?.statusText || '').trim(); + const stateLabel = getStatusLabel(state); + const stateSeverity = getStatusSeverity(state); + const cdLive = context?.cdLive && typeof context.cdLive === 'object' ? context.cdLive : {}; + const cdLiveTrackStates = Array.isArray(cdLive?.trackStates) ? cdLive.trackStates : []; + const cdLiveTrackStateByPosition = new Map( + cdLiveTrackStates + .map((item) => [normalizePosition(item?.position), item]) + .filter(([position]) => position !== null) + ); + const cdLiveSelectedTrackPositions = Array.isArray(cdLive?.selectedTrackPositions) + ? cdLive.selectedTrackPositions + .map((value) => normalizePosition(value)) + .filter((value) => value !== null) + : []; + const cdLiveSelectedTrackSet = new Set(cdLiveSelectedTrackPositions.map((value) => String(value))); + const livePhase = String(cdLive?.phase || '').trim().toLowerCase(); + const livePhaseLabel = livePhase === 'encode' ? 'Encode' : 'Rip'; + const albumTitle = normalizeTrackText(metaFields?.title) + || normalizeTrackText(selectedMeta?.title) + || normalizeTrackText(context?.detectedTitle) + || '-'; + const fallbackArtistFromTracks = tracks + .map((track) => normalizeTrackText(track?.artist)) + .find(Boolean) || '-'; + const albumArtist = normalizeTrackText(metaFields?.artist) + || normalizeTrackText(selectedMeta?.artist) + || fallbackArtistFromTracks + || '-'; + const albumYear = normalizeYear(metaFields?.year) + ?? normalizeYear(selectedMeta?.year) + ?? '-'; + const musicBrainzId = normalizeTrackText( + selectedMeta?.mbId + || selectedMeta?.musicBrainzId + || selectedMeta?.musicbrainzId + || selectedMeta?.mbid + || '' + ) || '-'; + const coverUrl = normalizeTrackText( + selectedMeta?.coverUrl + || selectedMeta?.poster + || selectedMeta?.posterUrl + || '' + ) || null; + const devicePath = normalizeTrackText(context?.devicePath) || '-'; + const outputPath = normalizeTrackText(context?.outputPath) || '-'; + const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase(); + const formatLabel = (Array.isArray(CD_FORMATS) + ? CD_FORMATS.find((entry) => String(entry?.value || '').trim().toLowerCase() === formatValue)?.label + : null) || (formatValue ? formatValue.toUpperCase() : '-'); + const effectiveTrackRows = tracks + .map((track) => { + const position = normalizePosition(track?.position); + if (!position) { + return null; + } + const selected = cdLiveSelectedTrackSet.size > 0 + ? cdLiveSelectedTrackSet.has(String(position)) + : (selectedTracks[position] !== false); + const trackDurationSec = Number.isFinite(Number(track?.durationMs)) && Number(track.durationMs) > 0 + ? Math.round(Number(track.durationMs) / 1000) + : (Number.isFinite(Number(track?.durationSec)) && Number(track.durationSec) > 0 + ? Math.round(Number(track.durationSec)) + : 0); + const liveTrackState = cdLiveTrackStateByPosition.get(position) || null; + const fallbackRipStatus = isFinished && selected ? 'done' : 'pending'; + const fallbackEncodeStatus = isFinished && selected ? 'done' : 'pending'; + return { + position, + selected, + title: normalizeTrackText(trackFields?.[position]?.title) + || normalizeTrackText(track?.title) + || `Track ${position}`, + artist: normalizeTrackText(trackFields?.[position]?.artist) + || normalizeTrackText(track?.artist) + || normalizeTrackText(selectedMeta?.artist) + || '-', + durationLabel: formatTrackDuration(track), + durationSec: trackDurationSec, + ripStatus: normalizeTrackStageStatus(liveTrackState?.ripStatus || fallbackRipStatus), + encodeStatus: normalizeTrackStageStatus(liveTrackState?.encodeStatus || fallbackEncodeStatus) + }; + }) + .filter(Boolean); + if (effectiveTrackRows.length === 0 && cdLiveTrackStates.length > 0) { + for (const trackState of cdLiveTrackStates) { + const position = normalizePosition(trackState?.position); + if (!position) { + continue; + } + const trackDurationSec = Number(trackState?.durationSec); + effectiveTrackRows.push({ + position, + selected: true, + title: normalizeTrackText(trackState?.title) || `Track ${position}`, + artist: normalizeTrackText(trackState?.artist) || '-', + durationLabel: formatTotalDuration(trackDurationSec), + durationSec: Number.isFinite(trackDurationSec) && trackDurationSec > 0 ? Math.trunc(trackDurationSec) : 0, + ripStatus: normalizeTrackStageStatus(trackState?.ripStatus), + encodeStatus: normalizeTrackStageStatus(trackState?.encodeStatus) + }); + } + } + const selectedTrackRows = effectiveTrackRows.filter((track) => track.selected); + const displayTrackRows = selectedTrackRows.length > 0 ? selectedTrackRows : effectiveTrackRows; + const selectedTrackNumbers = selectedTrackRows + .map((track) => String(track.position).padStart(2, '0')) + .join(', '); + const selectedTrackDurationSec = selectedTrackRows.reduce( + (sum, track) => sum + (Number.isFinite(track.durationSec) ? track.durationSec : 0), + 0 + ); + const completedRipCount = selectedTrackRows.filter((track) => track.ripStatus === 'done').length; + const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length; + const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition); + const lastState = String(context?.lastState || '').trim().toUpperCase(); + const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const postScriptNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeScripts) ? cdRipConfig.postEncodeScripts : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const preChainNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeChains) ? cdRipConfig.preEncodeChains : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const postChainNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeChains) ? cdRipConfig.postEncodeChains : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const preScriptNames = preScriptNamesFromConfig.length > 0 + ? preScriptNamesFromConfig + : preRipItems + .filter((item) => item?.type === 'script') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const postScriptNames = postScriptNamesFromConfig.length > 0 + ? postScriptNamesFromConfig + : postRipItems + .filter((item) => item?.type === 'script') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const preChainNames = preChainNamesFromConfig.length > 0 + ? preChainNamesFromConfig + : preRipItems + .filter((item) => item?.type === 'chain') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const postChainNames = postChainNamesFromConfig.length > 0 + ? postChainNamesFromConfig + : postRipItems + .filter((item) => item?.type === 'chain') + .map((item) => describeEncodeItem(item, scriptById, chainById)); - if (isRipping || isFinished) { + if (isRipping || isFinished || isTerminalFailure) { return (
-
- - {statusText ? {statusText} : null} - 1) {cdparanoiaCommandPreview} - {encodeCommandPreview ? 2) {encodeCommandPreview} : null} - {!isFinished ? ( - <> - - {Math.round(clampedProgress)}%{eta ? ` | ETA ${eta}` : ''} - - ) : null} +
+ + {statusText || 'Bereit'}
- {!isFinished ? ( -
+ ) : null} + {isTerminalFailure ? ( +
+ {jobId ? ( +
+ ) : null} + +
+ CD-Details +
+ {coverUrl ? ( +
+ {`Cover +
+ ) : null} +
+
Album: {albumTitle}
+
Interpret: {albumArtist}
+
Jahr: {albumYear}
+
MusicBrainz: {musicBrainzId}
+
Status: {stateLabel}
+
Format: {formatLabel}
+
Rip fertig: {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Encode fertig: {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Aktueller Track: {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}
+
Pre-Skripte: {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}
+
Pre-Ketten: {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}
+
Post-Skripte: {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}
+
Post-Ketten: {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}
+
Tracks fertig: {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Auswahl: {selectedTrackNumbers || '-'}
+
Gesamtdauer: {formatTotalDuration(selectedTrackDurationSec)}
+
Laufwerk: {devicePath}
+
Output-Pfad: {outputPath}
+ {lastState ?
Letzter Pipeline-State: {lastState}
: null} + {jobId ?
Job-ID: #{jobId}
: null} +
+
+
+ + {displayTrackRows.length > 0 ? ( +
+ Zu rippende Tracks ({displayTrackRows.length}) +
+ + + + + + + + + + + + + + {displayTrackRows.map((track) => { + const ripMeta = trackStatusTagMeta(track?.ripStatus); + const encodeMeta = trackStatusTagMeta(track?.encodeStatus); + return ( + + + + + + + + + + ); + })} + +
AuswahlNrInterpretTitelLängeRipEncode
{track.selected ? 'Ja' : 'Nein'}{String(track.position).padStart(2, '0')}{track.artist || '-'}{track.title || '-'}{track.durationLabel}
+
+
) : null}
); @@ -644,14 +1021,244 @@ export default function CdRipConfigPanel({ ) : null} -
- - 1) {cdparanoiaCommandPreview} - {encodeCommandPreview ? 2) {encodeCommandPreview} : null} +
+

Pre-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {preRipItems.length === 0 ? ( + Keine Pre-Rip Ausführungen ausgewählt. + ) : null} + {preRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('pre', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('pre', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen. +
+ +
+

Post-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {postRipItems.length === 0 ? ( + Keine Post-Rip Ausführungen ausgewählt. + ) : null} + {postRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('post', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('post', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.
{/* Actions */}
+ {jobId ? ( +
- RAW Pfad: {job.raw_path || '-'} + {isCd ? 'WAV Pfad:' : 'RAW Pfad:'} {job.raw_path || '-'}
Output: {job.output_path || '-'}
-
- Encode Input: {job.encode_input_path || '-'} -
+ {!isCd ? ( +
+ Encode Input: {job.encode_input_path || '-'} +
+ ) : null}
RAW vorhanden:
- Movie Datei vorhanden: -
-
- Backup erfolgreich: -
-
- Encode erfolgreich: + {isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}
+ {isCd ? ( +
+ Rip erfolgreich: +
+ ) : ( + <> +
+ Backup erfolgreich: +
+
+ Encode erfolgreich: +
+ + )}
Letzter Fehler: {job.error_message || '-'}
- {hasConfiguredSelection || encodePlanUserPreset ? ( + {!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (

Hinterlegte Encode-Auswahl

@@ -501,7 +677,7 @@ export default function JobDetailDialog({
) : null} - {executedHandBrakeCommand ? ( + {!isCd && executedHandBrakeCommand ? (

Ausgeführter Encode-Befehl

@@ -511,7 +687,7 @@ export default function JobDetailDialog({
) : null} - {(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? ( + {!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (

Skripte

@@ -522,14 +698,14 @@ export default function JobDetailDialog({ ) : null}
- - - - - + {!isCd ? : null} + + {!isCd ? : null} + + {!isCd ? : null}
- {job.encodePlan ? ( + {!isCd && job.encodePlan ? ( <>

Mediainfo-Prüfung (Auswertung)

) : ( <> -
{(() => { - const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase(); - const isCdJob = jobState.startsWith('CD_'); if (isCdJob) { return ( <> - {jobState === 'CD_METADATA_SELECTION' ? ( -