diff --git a/backend/package-lock.json b/backend/package-lock.json index 73ca7c9..bcbd061 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-1", + "version": "0.12.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-1", + "version": "0.12.0-2", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index cbec49e..60e05ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-1", + "version": "0.12.0-2", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 46d9c7c..6a8bcdd 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -1003,7 +1003,7 @@ async function migrateSettingsSchemaMetadata(db) { // ✅ = plugin-pfad aktiv ⏳ = noch nicht implementiert (readonly, immer aus) // // BluRay / DVD: Analyse ✅ Rip ✅ Review ⏳ Encode ⏳ - // CD: Analyse ✅ Rip ⏳ + // CD: Analyse ✅ Rip ✅ // Audiobook: Analyse ✅ Encode ⏳ const pluginArchitectureSettings = [ @@ -1130,12 +1130,12 @@ async function migrateSettingsSchemaMetadata(db) { }, { key: 'use_plugin_architecture_cd_rip', - label: 'Rip + Encode (Audio-CD)', - description: 'Noch nicht implementiert — cdparanoia-Rip läuft noch über Legacy.', - defaultValue: 'false', + label: 'Rip (Audio-CD)', + description: 'CD-Rip/RAW-Rip/Encode-aus-RAW läuft über das Plugin.', + defaultValue: 'true', orderIndex: 10022, dependsOn: 'use_plugin_architecture_cd', - validationJson: '{"readonly":true}' + validationJson: '{}' }, // ── Audiobook ──────────────────────────────────────────────────────────── { @@ -1178,6 +1178,13 @@ async function migrateSettingsSchemaMetadata(db) { WHERE key = ? AND (depends_on IS NOT ? OR order_index != ? OR validation_json != ?)`, [setting.dependsOn ?? null, setting.orderIndex, setting.validationJson, setting.key, setting.dependsOn ?? null, setting.orderIndex, setting.validationJson] ); + await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, default_value = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = ? + AND (label != ? OR description != ? OR COALESCE(default_value, '') != COALESCE(?, ''))`, + [setting.label, setting.description, setting.defaultValue, setting.key, setting.label, setting.description, setting.defaultValue] + ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]); // Readonly-Toggles immer auf 'false' zurücksetzen (nie aktivierbar) if (setting.validationJson === '{"readonly":true}') { diff --git a/backend/src/plugins/CdPlugin.js b/backend/src/plugins/CdPlugin.js index 3b46ded..7d206a9 100644 --- a/backend/src/plugins/CdPlugin.js +++ b/backend/src/plugins/CdPlugin.js @@ -94,9 +94,9 @@ class CdPlugin extends SourcePlugin { * läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf. * * Pflicht-Felder in ctx.extra: - * ripConfig - { format, formatOptions, selectedTracks, tracks, metadata } + * ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode } * rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner) - * outputDir - Finaler Ausgabeordner + * outputDir - Finaler Ausgabeordner (optional bei skipEncode=true) * outputTemplate - Template-String für Dateinamen * isCancelled - () => boolean * onProcessHandle - (handle) => void [optional] @@ -118,13 +118,18 @@ class CdPlugin extends SourcePlugin { outputDir, outputTemplate, isCancelled, - onProcessHandle + onProcessHandle, + onProgress: onRawProgress, + onLog: onExternalLog } = ctx.extra || {}; if (!rawWavDir) { throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt'); } - if (!outputDir) { + const skipRip = Boolean(ripConfig?.skipRip); + const skipEncode = Boolean(ripConfig?.skipEncode); + + if (!skipEncode && !outputDir) { throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt'); } @@ -132,8 +137,7 @@ class CdPlugin extends SourcePlugin { const settings = await ctx.settings.getSettingsMap(); const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; const devicePath = String(job?.disc_device || '').trim(); - - if (!devicePath) { + if (!devicePath && !skipRip) { throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).'); } @@ -189,14 +193,16 @@ class CdPlugin extends SourcePlugin { ctx.logger.info('cd:rip:start', { jobId: job?.id, - devicePath, + devicePath: devicePath || null, + skipRip, + skipEncode, format, trackCount: selectedTrackPositions.length }); const result = await cdRipService.ripAndEncode({ jobId: job?.id, - devicePath, + devicePath: devicePath || null, cdparanoiaCmd, rawWavDir, outputDir, @@ -206,7 +212,12 @@ class CdPlugin extends SourcePlugin { tracks: mergedTracks, meta, outputTemplate: effectiveTemplate, + skipRip, + skipEncode, onProgress: (progressEvent) => { + if (typeof onRawProgress === 'function') { + onRawProgress(progressEvent); + } const pct = Number(progressEvent?.percent ?? 0); const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe'; const trackIdx = progressEvent?.trackIndex || 1; @@ -220,6 +231,9 @@ class CdPlugin extends SourcePlugin { if (ctx.logger[level]) { ctx.logger[level](msg, { jobId: job?.id }); } + if (typeof onExternalLog === 'function') { + onExternalLog(level, msg); + } }, onProcessHandle, isCancelled, diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 3a81174..34ae80e 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -148,6 +148,10 @@ router.post( 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()); + const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); + const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined + ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) + : !resetDriveState; const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) ? req.body.selectedMoviePaths .map((item) => String(item || '').trim()) @@ -159,11 +163,29 @@ router.post( id, target, includeRelated, + resetDriveState, + keepDetectedDevice, selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 }); + const preview = resetDriveState + ? await historyService.getJobDeletePreview(id, { includeRelated }) + : null; + const relatedDevicePaths = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs + .map((row) => String(row?.discDevice || '').trim()) + .filter(Boolean) + : []; const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths }); - const uiReset = await pipelineService.resetFrontendState('history_delete'); + await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], { + resetDriveState, + devicePaths: relatedDevicePaths + }).catch((error) => { + logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) }); + }); + const uiReset = await pipelineService.resetFrontendState('history_delete', { + keepDetectedDevice + }); res.json({ ...result, uiReset }); }) ); diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index 5a3e1d6..36c5103 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -22,13 +22,16 @@ const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artis function parseToc(tocOutput) { const lines = String(tocOutput || '').split(/\r?\n/); const tracks = []; + const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g; + const tocTablePattern = + /^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i; for (const line of lines) { const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i); if (trackMatch) { const position = Number(trackMatch[1]); const payloadWithoutTimes = String(trackMatch[2] || '') - .replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' '); + .replace(tocTimePattern, ' '); const sectorValues = payloadWithoutTimes.match(/\d+/g) || []; if (sectorValues.length < 2) { continue; @@ -58,9 +61,7 @@ function parseToc(tocOutput) { // Alternative cdparanoia -Q table style: // 1. 16503 [03:40.03] 0 [00:00.00] no no 2 // ^ length sectors ^ start sector - const tableMatch = line.match( - /^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i - ); + const tableMatch = line.match(tocTablePattern); if (!tableMatch) { continue; } @@ -395,6 +396,7 @@ async function runProcessTracked({ * @param {object[]} options.tracks - TOC track list [{position, durationMs, title}] * @param {object} options.meta - album metadata {title, artist, year} * @param {string} options.outputTemplate - template for relative output path without extension + * @param {boolean} options.skipEncode - true => rip only (no encode) * @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void * @param {Function} options.onLog - (level, msg) => void * @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration @@ -419,10 +421,11 @@ async function ripAndEncode(options) { onProcessHandle, isCancelled, context, - skipRip = false + skipRip = false, + skipEncode = false } = options; - if (!SUPPORTED_FORMATS.has(format)) { + if (!skipEncode && !SUPPORTED_FORMATS.has(format)) { throw new Error(`Unbekanntes Ausgabeformat: ${format}`); } @@ -435,7 +438,9 @@ async function ripAndEncode(options) { } await ensureDir(rawWavDir); - await ensureDir(outputDir); + if (!skipEncode) { + await ensureDir(outputDir); + } logger.info('rip:start', { jobId, @@ -448,6 +453,9 @@ async function ripAndEncode(options) { logger[level] && logger[level](msg, { jobId }); onLog && onLog(level, msg); }; + const ripPercentSpan = skipEncode ? 100 : 50; + const encodePercentStart = ripPercentSpan; + const encodePercentSpan = Math.max(0, 100 - encodePercentStart); // ── Phase 1: Rip each selected track to WAV ────────────────────────────── if (skipRip) { @@ -472,7 +480,7 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 0, - percent: (i / tracksToRip.length) * 50 + percent: (i / tracksToRip.length) * ripPercentSpan }); log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`); @@ -486,7 +494,7 @@ async function ripAndEncode(options) { onStderrLine(line) { const parsed = parseCdParanoiaProgress(line); if (parsed && parsed.percent !== null) { - const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50; + const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan; onProgress && onProgress({ phase: 'rip', trackEvent: 'progress', @@ -518,13 +526,22 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 100, - percent: ((i + 1) / tracksToRip.length) * 50 + percent: ((i + 1) / tracksToRip.length) * ripPercentSpan }); log('info', `Track ${track.position} gerippt.`); } } // end if (!skipRip) + if (skipEncode) { + return { + outputDir: null, + format: 'raw', + trackCount: tracksToRip.length, + encodeResults: [] + }; + } + // ── Phase 2: Encode WAVs to target format ───────────────────────────────── const encodeResults = []; @@ -542,7 +559,7 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 0, - percent: 50 + ((i / tracksToRip.length) * 50) + percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan) }); ensureDir(path.dirname(outFile)); log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`); @@ -554,7 +571,7 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 100, - percent: 50 + ((i + 1) / tracksToRip.length) * 50 + percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan }); log('info', `WAV für Track ${track.position} gespeichert.`); encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true }); @@ -581,7 +598,7 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 0, - percent: 50 + ((i / tracksToRip.length) * 50) + percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan) }); log('info', `Encodiere Track ${track.position} → ${outFilename} …`); @@ -628,7 +645,7 @@ async function ripAndEncode(options) { trackTotal: tracksToRip.length, trackPosition: track.position, trackPercent: 100, - percent: 50 + ((i + 1) / tracksToRip.length) * 50 + percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan }); log('info', `Track ${track.position} encodiert.`); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index 7a5ac79..aa59114 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -159,6 +159,11 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { return null; } +function isIsoLikeFsType(rawFsType) { + const fstype = String(rawFsType || '').trim().toLowerCase(); + return fstype.includes('iso9660') || fstype.includes('cdfs'); +} + function inferMediaProfileFromUdevProperties(properties = {}) { const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => { const normalizedKey = String(key || '').trim().toUpperCase(); @@ -535,6 +540,19 @@ class DiskDetectionService extends EventEmitter { if (!normalized) { return { present: false, emitted: 'none', device: null }; } + if (this.isDeviceLocked(normalized)) { + const existing = this.detectedDiscs.get(normalized) || null; + logger.info('rescan-drive:skip-locked', { + devicePath: normalized, + activeLocks: this.getActiveLocks() + }); + return { + present: Boolean(existing), + emitted: 'none', + device: existing, + locked: true + }; + } try { logger.info('rescan-drive:requested', { devicePath: normalized }); const detected = await this.detectExplicit(normalized); @@ -598,6 +616,16 @@ class DiskDetectionService extends EventEmitter { } } + // Preserve currently tracked locked devices that are intentionally skipped + // by detectAll* while a rip lock is active. + for (const [devicePath, device] of this.detectedDiscs) { + if (newMap.has(devicePath) || !this.isDeviceLocked(devicePath)) { + continue; + } + newMap.set(devicePath, device); + results.push({ path: devicePath, emitted: 'none', device, locked: true }); + } + // Check for removed devices for (const [devicePath, device] of this.detectedDiscs) { if (!newMap.has(devicePath)) { @@ -652,9 +680,15 @@ class DiskDetectionService extends EventEmitter { if (!mediaState.hasMedia && hasSizeMedia) { logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes }); } - const mediaType = mediaState.type; + const mediaType = String(mediaState.type || '').trim().toLowerCase() || null; const discLabel = await this.getDiscLabel(devicePath); - const detectedFsType = String(match.fstype || mediaType || '').trim() || null; + // Preserve explicit audio-CD detection from checkMediaPresent even if lsblk + // reports ambiguous optical fs markers like iso9660/cdfs. + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (match.fstype || mediaType || '') + ).trim() || null; const mediaProfile = await this.inferMediaProfile(devicePath, { discLabel, @@ -702,8 +736,13 @@ class DiskDetectionService extends EventEmitter { if (!mediaState.hasMedia) { continue; } + const mediaType = String(mediaState.type || '').trim().toLowerCase() || null; const discLabel = await this.getDiscLabel(path); - const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (item.fstype || mediaType || '') + ).trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, @@ -764,9 +803,13 @@ class DiskDetectionService extends EventEmitter { if (!mediaState.hasMedia && hasSizeMedia) { logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes }); } - const mediaType = mediaState.type; + const mediaType = String(mediaState.type || '').trim().toLowerCase() || null; const discLabel = await this.getDiscLabel(path); - const detectedFsType = String(item.fstype || mediaType || '').trim() || null; + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (item.fstype || mediaType || '') + ).trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, @@ -871,6 +914,8 @@ class DiskDetectionService extends EventEmitter { return { hasMedia: true, type: blkidType }; } + let hasOpticalMediaHintFromUdev = false; + // blkid found nothing – audio CDs have no filesystem, so fall back to udevadm try { const { stdout } = await execFileAsync('udevadm', [ @@ -902,7 +947,10 @@ class DiskDetectionService extends EventEmitter { } if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') { logger.debug('udevadm:optical-media', { devicePath, inferredByUdev }); - return { hasMedia: true, type: null }; + // Keep this as a presence hint, but still probe cdparanoia. Some drives + // expose mixed DVD/CD flags for audio CDs and would otherwise be + // downgraded to "other" before TOC probing. + hasOpticalMediaHintFromUdev = true; } } catch (_udevError) { // udevadm not available or failed – ignore @@ -919,6 +967,10 @@ class DiskDetectionService extends EventEmitter { return { hasMedia: true, type: 'audio_cd' }; } + if (hasOpticalMediaHintFromUdev) { + return { hasMedia: true, type: null }; + } + // Final fallback: check block device size via sysfs. // In VM/passthrough environments udev metadata may be absent even though // the kernel reports a valid disc size (visible in lsblk). A non-zero @@ -1046,7 +1098,12 @@ class DiskDetectionService extends EventEmitter { // Also guard: when hintFstype is empty (no filesystem info at all), the drive model // alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD. // In that case skip this early return and let blkid -p determine the actual disc type. - if (hintFstype && byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { + if ( + hintFstype + && byFsTypeHint + && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray') + && !(isIsoLikeFsType(hintFstype) && byFsTypeHint === 'dvd') + ) { return byFsTypeHint; } @@ -1090,14 +1147,14 @@ class DiskDetectionService extends EventEmitter { } const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model); - if (byBlkidFsType) { + if (byBlkidFsType && !(isIsoLikeFsType(type) && byBlkidFsType === 'dvd')) { return byBlkidFsType; } // Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID: // prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc. const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null); - if (byBlkidFsTypeWithoutModel) { + if (byBlkidFsTypeWithoutModel && !(isIsoLikeFsType(type) && byBlkidFsTypeWithoutModel === 'dvd')) { return byBlkidFsTypeWithoutModel; } } catch (error) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 06dfeb7..665b9ae 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4114,6 +4114,7 @@ class HistoryService { parentJobId: normalizeJobIdValue(job.parent_job_id), title: buildJobDisplayTitle(job), status: String(job.status || '').trim() || null, + discDevice: String(job.disc_device || '').trim() || null, isPrimary: Number(job.id) === normalizedJobId, createdAt: String(job.created_at || '').trim() || null })); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 25bc859..1c62e65 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -3941,6 +3941,7 @@ class PipelineService extends EventEmitter { }; this.detectedDisc = null; this.cdDrives = new Map(); // devicePath → per-drive CD state object + this.driveLocksByJob = new Map(); // jobId -> { devicePath, owner } this.activeProcess = null; this.activeProcesses = new Map(); this.cancelRequestedByJob = new Set(); @@ -4270,14 +4271,20 @@ class PipelineService extends EventEmitter { async buildPluginContext(pluginId, extra = {}) { const { PluginContext } = require('../plugins/PluginContext'); const normalizedJobId = this.normalizeQueueJobId(extra?.jobId ?? extra?.job?.id); + const emitProgress = typeof extra?.emitProgress === 'function' + ? extra.emitProgress + : () => {}; + const emitState = typeof extra?.emitState === 'function' + ? extra.emitState + : () => {}; return new PluginContext({ settings: settingsService, db: await getDb(), logger: require('./logger').child(`PLUGIN:${String(pluginId || 'unknown').trim().toUpperCase() || 'UNKNOWN'}`), websocket: wsService, processRunner: { spawnTrackedProcess }, - emitProgress: () => {}, - emitState: () => {}, + emitProgress, + emitState, onPluginExecution: (marker, aggregateState) => { void this.applyPluginExecutionMarker(marker, aggregateState); }, @@ -4603,6 +4610,14 @@ class PipelineService extends EventEmitter { }); } + try { + await this._restoreDriveLocksFromJobs(); + } catch (driveLockRecoveryError) { + logger.warn('init:drive-lock-recovery-failed', { + error: errorToMeta(driveLockRecoveryError) + }); + } + // Always start with a clean dashboard/session snapshot after server restart. const hasContextKeys = this.snapshot.context && typeof this.snapshot.context === 'object' @@ -4707,7 +4722,9 @@ class PipelineService extends EventEmitter { } } - await historyService.updateJobStatus(jobId, 'ERROR', { + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: stage, end_time: nowIso(), error_message: message }); @@ -4759,12 +4776,14 @@ class PipelineService extends EventEmitter { for (const [path, device] of diskDetectionService.detectedDiscs) { detectedDiscs[path] = device; } + const driveLocks = diskDetectionService.getActiveLocks(); return { ...this.snapshot, jobProgress, queue: this.lastQueueSnapshot, cdDrives, - detectedDiscs + detectedDiscs, + driveLocks }; } @@ -4897,6 +4916,318 @@ class PipelineService extends EventEmitter { }); } + normalizeDrivePath(value) { + return String(value || '').trim(); + } + + _buildDriveLockOwner(jobId, extra = {}) { + const normalizedJobId = Number(jobId); + return { + jobId: Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? Math.trunc(normalizedJobId) : null, + stage: String(extra?.stage || '').trim().toUpperCase() || null, + source: String(extra?.source || '').trim() || null, + mediaProfile: normalizeMediaProfile(extra?.mediaProfile) || null, + reason: String(extra?.reason || '').trim() || null, + acquiredAt: nowIso() + }; + } + + _getDriveLockByJobId(jobId) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return null; + } + return this.driveLocksByJob.get(Math.trunc(normalizedJobId)) || null; + } + + _getDriveLockByPath(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return null; + } + for (const [jobId, lock] of this.driveLocksByJob.entries()) { + if (this.normalizeDrivePath(lock?.devicePath) === normalizedPath) { + return { + ...lock, + jobId: Number(jobId) + }; + } + } + return null; + } + + _acquireDriveLockForJob(devicePath, jobId, extra = {}) { + const normalizedPath = this.normalizeDrivePath(devicePath); + const normalizedJobId = Number(jobId); + if (!normalizedPath || !Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + + const targetJobId = Math.trunc(normalizedJobId); + const existing = this._getDriveLockByJobId(targetJobId); + if (existing && this.normalizeDrivePath(existing.devicePath) === normalizedPath) { + return true; + } + if (existing) { + this._releaseDriveLockForJob(targetJobId, { reason: 'rebind' }); + } + + const owner = this._buildDriveLockOwner(targetJobId, extra); + diskDetectionService.lockDevice(normalizedPath, owner); + this.driveLocksByJob.set(targetJobId, { + devicePath: normalizedPath, + owner + }); + logger.info('drive-lock:acquired', { + devicePath: normalizedPath, + jobId: targetJobId, + stage: owner.stage, + source: owner.source, + reason: owner.reason + }); + return true; + } + + _releaseDriveLockForJob(jobId, options = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + const targetJobId = Math.trunc(normalizedJobId); + const existing = this.driveLocksByJob.get(targetJobId); + if (!existing) { + return false; + } + + diskDetectionService.unlockDevice(existing.devicePath, existing.owner || null); + this.driveLocksByJob.delete(targetJobId); + logger.info('drive-lock:released', { + devicePath: existing.devicePath, + jobId: targetJobId, + reason: String(options?.reason || '').trim() || null + }); + return true; + } + + _buildDriveLockedError(devicePath, lock = null) { + const normalizedPath = this.normalizeDrivePath(devicePath) || ''; + const lockedByJobId = Number(lock?.jobId || lock?.owner?.jobId || 0) || null; + const stage = String(lock?.owner?.stage || '').trim().toUpperCase() || null; + const message = lockedByJobId + ? `Laufwerk ${normalizedPath} ist gesperrt (Job #${lockedByJobId}${stage ? `, ${stage}` : ''}).` + : `Laufwerk ${normalizedPath} ist derzeit gesperrt.`; + const error = new Error(message); + error.statusCode = 409; + error.details = [{ + field: 'devicePath', + message + }]; + return error; + } + + _shouldKeepDriveLockForJobRow(job = null) { + const status = String(job?.status || '').trim().toUpperCase(); + const lastState = String(job?.last_state || '').trim().toUpperCase(); + const ripSuccessful = Number(job?.rip_successful || 0) === 1; + if (!this.normalizeDrivePath(job?.disc_device)) { + return false; + } + if (status === 'RIPPING' || status === 'CD_RIPPING') { + return true; + } + if ((status === 'ERROR' || status === 'CANCELLED') && !ripSuccessful) { + return lastState === 'RIPPING' || lastState === 'CD_RIPPING'; + } + return false; + } + + async _restoreDriveLocksFromJobs() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, disc_device, status, last_state, rip_successful + FROM jobs + WHERE disc_device IS NOT NULL + AND TRIM(disc_device) <> '' + AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED') + ` + ); + let restored = 0; + for (const row of (Array.isArray(rows) ? rows : [])) { + if (!this._shouldKeepDriveLockForJobRow(row)) { + continue; + } + const jobId = Number(row?.id); + const devicePath = this.normalizeDrivePath(row?.disc_device); + if (!Number.isFinite(jobId) || jobId <= 0 || !devicePath) { + continue; + } + const acquired = this._acquireDriveLockForJob(devicePath, jobId, { + stage: String(row?.status || row?.last_state || '').trim().toUpperCase() || null, + source: 'startup_recovery', + reason: 'pending_rip_recovery' + }); + if (acquired) { + restored += 1; + } + } + if (restored > 0) { + logger.warn('drive-lock:restored', { restored }); + } + } + + async _cleanupReplaceableDriveJobsForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return []; + } + + const existingLock = this._getDriveLockByPath(normalizedPath); + if (diskDetectionService.isDeviceLocked(normalizedPath) || existingLock) { + throw this._buildDriveLockedError(normalizedPath, existingLock); + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE disc_device = ? + ORDER BY updated_at DESC, id DESC + `, + [normalizedPath] + ); + const replaceableStates = new Set([ + 'ANALYZING', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'MEDIAINFO_CHECK', + 'READY_TO_ENCODE', + 'CD_ANALYZING', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP', + 'ERROR', + 'CANCELLED' + ]); + const deleted = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const normalizedJobId = Math.trunc(jobId); + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!replaceableStates.has(rowState)) { + continue; + } + if (this.activeProcesses.has(normalizedJobId)) { + const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); + error.statusCode = 409; + throw error; + } + if (this._shouldKeepDriveLockForJobRow(row)) { + throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByJobId(normalizedJobId)); + } + await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); + await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); + deleted.push(normalizedJobId); + } + + if (deleted.length > 0) { + logger.info('analyze:drive:replace-existing-jobs', { + devicePath: normalizedPath, + deletedJobIds: deleted + }); + } + return deleted; + } + + async onJobsDeleted(jobIds = [], options = {}) { + const normalizedIds = Array.isArray(jobIds) + ? jobIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : []; + if (normalizedIds.length === 0) { + return { cleaned: 0, removedQueueEntries: 0 }; + } + const resetDriveState = Boolean(options?.resetDriveState); + const extraDevicePaths = Array.isArray(options?.devicePaths) + ? options.devicePaths + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value)) + : []; + const affectedDevicePaths = new Set(extraDevicePaths); + + const queueIds = new Set(normalizedIds.map((value) => String(value))); + const previousQueueLength = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => !queueIds.has(String(Number(entry?.jobId || 0)))); + const removedQueueEntries = previousQueueLength - this.queueEntries.length; + + for (const jobId of normalizedIds) { + this.cancelRequestedByJob.delete(jobId); + const lockForJob = this._getDriveLockByJobId(jobId); + if (lockForJob?.devicePath) { + affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath)); + } + this._releaseDriveLockForJob(jobId, { reason: 'job_deleted' }); + const cdDriveForJob = this._getCdDriveByJobId(jobId); + if (cdDriveForJob?.devicePath) { + affectedDevicePaths.add(this.normalizeDrivePath(cdDriveForJob.devicePath)); + if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + if (resetDriveState) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); + } + } + } else { + this.jobProgress.delete(jobId); + } + this.activeProcesses.delete(jobId); + } + + let driveResetChanged = false; + if (resetDriveState) { + for (const devicePath of affectedDevicePaths) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { + continue; + } + const removedDisc = diskDetectionService.detectedDiscs.get(normalizedPath) || null; + if (removedDisc) { + diskDetectionService.detectedDiscs.delete(normalizedPath); + wsService.broadcast('DISC_REMOVED', { device: removedDisc }); + driveResetChanged = true; + } + if (this.detectedDisc && this.normalizeDrivePath(this.detectedDisc.path) === normalizedPath) { + this.detectedDisc = null; + driveResetChanged = true; + } + } + } + this.syncPrimaryActiveProcess(); + + if (driveResetChanged) { + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + } + + if (!options?.suppressQueueRefresh) { + await this.emitQueueChanged(); + void this.pumpQueue(); + } + return { + cleaned: normalizedIds.length, + removedQueueEntries + }; + } + normalizeParallelJobsLimit(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 1) { @@ -6506,19 +6837,26 @@ class PipelineService extends EventEmitter { async analyzeDisc(devicePath = null) { this.ensureNotBusy('analyze'); logger.info('analyze:start', { devicePath }); + const requestedDevicePath = this.normalizeDrivePath(devicePath); + if (requestedDevicePath) { + const existingLock = this._getDriveLockByPath(requestedDevicePath); + if (diskDetectionService.isDeviceLocked(requestedDevicePath) || existingLock) { + throw this._buildDriveLockedError(requestedDevicePath, existingLock); + } + } let device; - if (devicePath) { - const driveEntry = this.cdDrives.get(devicePath); + if (requestedDevicePath) { + const driveEntry = this.cdDrives.get(requestedDevicePath); device = driveEntry?.device || null; - if (!device && this.detectedDisc?.path === devicePath) { + if (!device && this.detectedDisc?.path === requestedDevicePath) { device = this.detectedDisc; } if (!device) { // Try the disk detection service's per-drive map — covers non-CD drives // not yet tracked by the global state machine (e.g. second DVD drive). - const diskDetected = diskDetectionService.detectedDiscs.get(devicePath); - device = diskDetected || { path: devicePath }; + const diskDetected = diskDetectionService.detectedDiscs.get(requestedDevicePath); + device = diskDetected || { path: requestedDevicePath }; } } else { device = this.detectedDisc || this.snapshot.context?.device; @@ -6549,14 +6887,20 @@ class PipelineService extends EventEmitter { ...device, mediaProfile }; + const effectiveAnalyzeDevicePath = this.normalizeDrivePath(deviceWithProfile?.path || requestedDevicePath); + if (effectiveAnalyzeDevicePath) { + await this._cleanupReplaceableDriveJobsForAnalyze(effectiveAnalyzeDevicePath); + } - // Fallback for Audio-CDs without filesystem markers: - // if profile inference ended up as non-CD but the drive reports no FS type, - // probe the TOC directly and force CD routing when tracks are present. + // Fallback for Audio-CDs with ambiguous filesystem markers: + // if profile inference ended up as non-CD and the drive reports either no FS type + // or iso9660/cdfs, probe the TOC directly and force CD routing when tracks exist. + const analyzeFsType = String(deviceWithProfile?.fstype || device?.fstype || '').trim().toLowerCase(); + const isAmbiguousCdFsType = !analyzeFsType || analyzeFsType.includes('iso9660') || analyzeFsType.includes('cdfs'); if ( mediaProfile !== 'cd' && String(device?.path || '').trim() - && !String(device?.fstype || '').trim() + && isAmbiguousCdFsType ) { try { const settingsMap = await settingsService.getSettingsMap(); @@ -10950,14 +11294,15 @@ class PipelineService extends EventEmitter { ); } const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); - if (devicePath) { - diskDetectionService.lockDevice(devicePath, { - jobId, + this._acquireDriveLockForJob(devicePath, jobId, { stage: 'RIPPING', - source: 'MAKEMKV_RIP' + source: 'MAKEMKV_RIP', + mediaProfile, + reason: 'rip_running' }); } + let ripCommandSucceeded = false; try { if (ripPlugin) { // Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand() @@ -10981,12 +11326,12 @@ class PipelineService extends EventEmitter { parser: parseMakeMkvProgress }); } + ripCommandSucceeded = true; } finally { - if (devicePath) { - diskDetectionService.unlockDevice(devicePath, { - jobId, - stage: 'RIPPING', - source: 'MAKEMKV_RIP' + if (devicePath && !ripCommandSucceeded) { + logger.warn('drive-lock:retained-after-rip-failure', { + devicePath, + jobId }); } } @@ -11059,6 +11404,10 @@ class PipelineService extends EventEmitter { } } + if (devicePath) { + this._releaseDriveLockForJob(jobId, { reason: 'rip_successful' }); + } + const review = await this.runReviewForRawJob(jobId, activeRawJobDir, { mode: 'rip', mediaProfile @@ -11381,6 +11730,7 @@ class PipelineService extends EventEmitter { 'USER_ACTION', `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).` ); + this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); await historyService.retireJobInFavorOf(jobId, retryJobId, { reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry') }); @@ -12544,9 +12894,33 @@ class PipelineService extends EventEmitter { } } + const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); + const normalizedJobLastState = String(job?.last_state || '').trim().toUpperCase(); + const ripStageForLockRecovery = ( + normalizedStage === 'CD_RIPPING' + || normalizedJobStatus === 'CD_RIPPING' + || normalizedJobLastState === 'CD_RIPPING' + ) + ? 'CD_RIPPING' + : ( + normalizedStage === 'RIPPING' + || normalizedJobStatus === 'RIPPING' + || normalizedJobLastState === 'RIPPING' + ) + ? 'RIPPING' + : null; + const shouldPreserveRipStageForLock = Boolean( + ripStageForLockRecovery + && Number(job?.rip_successful || 0) !== 1 + && (finalState === 'ERROR' || finalState === 'CANCELLED') + ); + const persistedLastState = shouldPreserveRipStageForLock + ? ripStageForLockRecovery + : finalState; + await historyService.updateJob(jobId, { status: finalState, - last_state: finalState, + last_state: persistedLastState, end_time: nowIso(), error_message: message }); @@ -12643,6 +13017,16 @@ class PipelineService extends EventEmitter { || String(this._getCdDriveByJobId(jobId)?.devicePath || '').trim() || null ); + const normalizedFailStage = String(stage || '').trim().toUpperCase(); + const keepCdDriveLockedOnCancel = Boolean( + isCancelled + && Number(job?.rip_successful || 0) !== 1 + && ( + this._getDriveLockByJobId(jobId) + || normalizedFailStage === 'CD_RIPPING' + || String(job?.last_state || '').trim().toUpperCase() === 'CD_RIPPING' + ) + ); if (resolvedCdDrivePath) { this._setCdDriveState(resolvedCdDrivePath, { @@ -12654,10 +13038,19 @@ class PipelineService extends EventEmitter { context: failContext }); if (isCancelled) { - if (String(resolvedCdDrivePath).startsWith('__virtual__')) { - this._removeCdDrive(resolvedCdDrivePath); + if (keepCdDriveLockedOnCancel) { + logger.warn('cd:drive:retain-lock-after-cancel', { + jobId, + devicePath: resolvedCdDrivePath, + stage: normalizedFailStage + }); } else { - this._releaseCdDrive(resolvedCdDrivePath); + this._releaseDriveLockForJob(jobId, { reason: 'cancelled_without_pending_rip' }); + if (String(resolvedCdDrivePath).startsWith('__virtual__')) { + this._removeCdDrive(resolvedCdDrivePath); + } else { + this._releaseCdDrive(resolvedCdDrivePath); + } } } } else { @@ -13861,6 +14254,45 @@ class PipelineService extends EventEmitter { } } + const canAutoStartRawRip = Boolean( + resolvedDevicePath + && !String(resolvedDevicePath).startsWith('__virtual__') + && Number(job?.rip_successful || 0) !== 1 + ); + if (canAutoStartRawRip) { + const autoRawRipConfig = { + selectedTracks: mergedTracks + .filter((track) => track?.selected !== false) + .map((track) => Number(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0), + tracks: mergedTracks, + metadata: { title, artist, year, mbId, coverUrl }, + skipEncode: true + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten bestätigt. Starte automatisch RAW-Rip (Encode-Auswahl folgt danach).' + ); + this.startCdRip(Number(jobId), autoRawRipConfig).then((result) => { + logger.info('cd:auto-raw-rip:started', { + jobId: Number(jobId), + replacedSourceJob: Boolean(result?.replacedSourceJob), + replacementJobId: Number(result?.jobId || 0) || null + }); + }).catch((error) => { + logger.error('cd:auto-raw-rip:failed', { + jobId: Number(jobId), + error: errorToMeta(error) + }); + historyService.appendLog( + Number(jobId), + 'SYSTEM', + `Automatischer RAW-Rip konnte nicht gestartet werden: ${error?.message || 'unknown'}` + ).catch(() => {}); + }); + } + return historyService.getJobById(jobId); } @@ -13998,6 +14430,14 @@ class PipelineService extends EventEmitter { let activeJobId = Number(jobId); let activeJob = sourceJob; + const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); + const sourceResolvedRawPath = this.resolveCurrentRawPathForSettings( + cdSettings, + 'cd', + sourceJob.raw_path + ); + const sourceHasSuccessfulRawRip = Number(sourceJob?.rip_successful || 0) === 1 + && Boolean(sourceResolvedRawPath); const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase(); const shouldReplaceSourceJob = sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'; if (shouldReplaceSourceJob) { @@ -14025,12 +14465,12 @@ class PipelineService extends EventEmitter { end_time: null, output_path: null, disc_device: sourceJob.disc_device || null, - raw_path: null, - rip_successful: 0, + raw_path: sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null, + rip_successful: sourceHasSuccessfulRawRip ? 1 : 0, makemkv_info_json: sourceJob.makemkv_info_json || null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: null, + encode_plan_json: sourceHasSuccessfulRawRip ? (sourceJob.encode_plan_json || null) : null, encode_input_path: null, encode_review_confirmed: 0 }); @@ -14047,6 +14487,7 @@ class PipelineService extends EventEmitter { 'USER_ACTION', `CD-Rip Neustart aus Job #${jobId}. Alter Job wurde durch neuen Job ersetzt.` ); + this._releaseDriveLockForJob(jobId, { reason: 'cd_restart_replaced' }); await historyService.retireJobInFavorOf(jobId, replacementJobId, { reason: 'cd_restart_rip' }); @@ -14061,7 +14502,17 @@ class PipelineService extends EventEmitter { const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; const devicePath = String(activeJob.disc_device || '').trim(); - const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip); + const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( + cdSettings, + 'cd', + activeJob.raw_path + ); + const hasSuccessfulRawRip = Number(activeJob?.rip_successful || 0) === 1 + && Boolean(resolvedExistingRawPath); + const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip || hasSuccessfulRawRip); + const skipEncode = Boolean(ripConfig?.skipEncode); + const resolvedCdRipPlugin = await this.resolveAnalyzePlugin({ mediaProfile: 'cd' }, 'rip').catch(() => null); + const cdRipPlugin = resolvedCdRipPlugin?.id === 'cd' ? resolvedCdRipPlugin : null; if (!devicePath && !skipRip) { const error = new Error('Kein CD-Laufwerk bekannt.'); @@ -14241,7 +14692,7 @@ class PipelineService extends EventEmitter { return { id: normalizedId, name }; }; - const settings = await settingsService.getEffectiveSettingsMap('cd'); + const settings = cdSettings; const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; const cdOutputTemplate = String( settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE @@ -14277,22 +14728,25 @@ class PipelineService extends EventEmitter { chownRecursive(rawJobDir, cdRawOwner); } - const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); - const baseOutputStillExists = (() => { - try { - return fs.existsSync(baseOutputDir); - } catch (_error) { - return false; - } - })(); - // Conflict strategy: - // - keepBoth => always number (_X) - // - replace + remaining sibling folders => continue numbering (_X) - // - replace + all removed => reuse base folder without numbering - const needsNumberedOutput = keepBothOutput || baseOutputStillExists; - const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; - ensureDir(outputDir); - chownRecursive(outputDir, cdOutputOwner); + let outputDir = null; + if (!skipEncode) { + const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); + const baseOutputStillExists = (() => { + try { + return fs.existsSync(baseOutputDir); + } catch (_error) { + return false; + } + })(); + // Conflict strategy: + // - keepBoth => always number (_X) + // - replace + remaining sibling folders => continue numbering (_X) + // - replace + all removed => reuse base folder without numbering + const needsNumberedOutput = keepBothOutput || baseOutputStillExists; + outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; + ensureDir(outputDir); + 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 ${effectiveDevicePath || ''} ${previewTrackPos} ${previewWavPath}`; @@ -14316,8 +14770,8 @@ class PipelineService extends EventEmitter { formatOptions, selectedTracks: effectiveSelectedTrackPositions, tracks: mergedTracks, - directReencodeReady: true, - directReencodeReadyAt: startedAt, + directReencodeReady: skipRip || !skipEncode, + directReencodeReadyAt: skipRip || !skipEncode ? startedAt : null, outputTemplate: cdOutputTemplate, outputConflict: { strategy: keepBothOutput ? 'keep_both' : 'replace', @@ -14355,6 +14809,7 @@ class PipelineService extends EventEmitter { // Only overwrite raw_path for normal rips; skipRip keeps the existing WAV directory if (!skipRip) { jobUpdate.raw_path = rawJobDir; + jobUpdate.rip_successful = 0; } await historyService.updateJob(activeJobId, jobUpdate); @@ -14364,7 +14819,7 @@ class PipelineService extends EventEmitter { jobId: activeJobId, progress: 0, eta: null, - statusText: skipRip ? 'Tracks werden encodiert …' : 'CD wird gerippt …', + statusText: skipRip ? 'Tracks werden encodiert …' : (skipEncode ? 'CD wird RAW gerippt …' : 'CD wird gerippt …'), context: { ...(existingCdDrive?.context || {}), jobId: activeJobId, @@ -14384,13 +14839,36 @@ class PipelineService extends EventEmitter { } }); - logger.info('cd:rip:start', { jobId: activeJobId, devicePath: effectiveDevicePath, skipRip, format, trackCount: effectiveSelectedTrackPositions.length }); + if (!skipRip && !String(effectiveDevicePath || '').startsWith('__virtual__')) { + this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { + stage: 'CD_RIPPING', + source: 'CDPARANOIA_RIP', + mediaProfile: 'cd', + reason: 'rip_running' + }); + } else { + this._releaseDriveLockForJob(activeJobId, { reason: 'encode_from_raw' }); + } + + logger.info('cd:rip:start', { + jobId: activeJobId, + devicePath: effectiveDevicePath, + skipRip, + skipEncode, + pluginId: cdRipPlugin?.id || null, + format, + trackCount: effectiveSelectedTrackPositions.length + }); await historyService.appendLog( activeJobId, 'SYSTEM', skipRip - ? `CD-Encode aus RAW gestartet (skipRip): Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` - : `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + ? `CD-Encode aus RAW gestartet (${cdRipPlugin ? 'Plugin' : 'Legacy'}): Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + : ( + skipEncode + ? `CD-RAW-Rip gestartet (${cdRipPlugin ? 'Plugin' : 'Legacy'}): Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + : `CD-Rip gestartet (${cdRipPlugin ? 'Plugin' : 'Legacy'}): Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + ) ); if ( selectedPreEncodeScripts.length > 0 @@ -14424,7 +14902,9 @@ class PipelineService extends EventEmitter { tocTracks: mergedTracks, selectedMeta: effectiveSelectedMeta, encodePlan: cdEncodePlan, - skipRip + ripPlugin: cdRipPlugin, + skipRip, + skipEncode }).catch((error) => { logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) }); }); @@ -14454,7 +14934,9 @@ class PipelineService extends EventEmitter { tocTracks, selectedMeta, encodePlan = null, - skipRip = false + ripPlugin = null, + skipRip = false, + skipEncode = false }) { const processKey = Number(jobId); let currentProcessHandle = null; @@ -14552,106 +15034,164 @@ class PipelineService extends EventEmitter { } } }; - const { encodeResults: cdEncodeResults = [] } = await cdRipService.ripAndEncode({ - jobId, - devicePath, - cdparanoiaCmd, - rawWavDir, - outputDir, - format, - formatOptions, - outputTemplate, - selectedTracks: selectedTrackPositions, - tracks: tocTracks, - meta: selectedMeta, - skipRip, - onProcessHandle: bindProcessHandle, - isCancelled: () => this.cancelRequestedByJob.has(processKey), - 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); - } else { - clampedPercent = Math.max(50, clampedPercent); - } - if (clampedPercent < lastProgressPercent) { - clampedPercent = lastProgressPercent; - } - clampedPercent = Number(clampedPercent.toFixed(2)); - lastProgressPercent = clampedPercent; + const handleRipProgress = 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 (skipEncode && normalizedPhase === 'rip') { + clampedPercent = Math.max(0, Math.min(100, clampedPercent)); + } else if (normalizedPhase === 'rip') { + clampedPercent = Math.min(clampedPercent, 50); + } else { + clampedPercent = Math.max(50, clampedPercent); + } + 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)); + if (normalizedPhase === 'rip') { + currentPhase = 'rip'; + currentTrackIndex = normalizedTrackIndex; + currentTrackPosition = normalizedTrackPosition; + if (normalizedTrackEvent === 'complete') { + ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackIndex); + if (ripCompletedCount >= normalizedTrackTotal) { + currentTrackPosition = null; } } 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)); - } + ripCompletedCount = Math.max(ripCompletedCount, Math.max(0, normalizedTrackIndex - 1)); } - - if (normalizedPhase === 'encode' && !encodeStateApplied) { - encodeStateApplied = true; - await historyService.updateJob(jobId, { - status: 'CD_ENCODING', - last_state: 'CD_ENCODING' - }); - } - - const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0 - ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})` - : ''; - const statusText = normalizedPhase === 'rip' - ? `CD wird gerippt …${detail}` - : `Tracks werden encodiert …${detail}`; - - await this.updateProgress(stage, clampedPercent, null, statusText, processKey, { - contextPatch: { - cdLive: buildLiveContext(null) + } 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 (!skipEncode && normalizedPhase === 'encode' && !encodeStateApplied) { + encodeStateApplied = true; + await historyService.updateJob(jobId, { + status: 'CD_ENCODING', + last_state: 'CD_ENCODING' }); - // Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates) - const currentCdDriveEntry = this.cdDrives.get(devicePath); - if (currentCdDriveEntry) { - this.cdDrives.set(devicePath, { - ...currentCdDriveEntry, - state: stage, - progress: clampedPercent, - statusText, - context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) } - }); + } + + const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0 + ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})` + : ''; + const statusText = normalizedPhase === 'rip' + ? `${skipEncode ? 'CD wird RAW gerippt' : 'CD wird gerippt'} …${detail}` + : `Tracks werden encodiert …${detail}`; + + await this.updateProgress(stage, clampedPercent, null, statusText, processKey, { + contextPatch: { + cdLive: buildLiveContext(null) } - }, - onLog: async (level, msg) => { - await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); - }, - context: { jobId: processKey } - }); + }); + // Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates) + const currentCdDriveEntry = this.cdDrives.get(devicePath); + if (currentCdDriveEntry) { + this.cdDrives.set(devicePath, { + ...currentCdDriveEntry, + state: stage, + progress: clampedPercent, + statusText, + context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) } + }); + } + }; + + let cdRipResult = null; + if (ripPlugin?.id === 'cd') { + const pluginJob = await historyService.getJobById(jobId); + const pluginCtx = await this.buildPluginContext(ripPlugin.id, { + jobId, + emitProgress: (progressPercent, statusText) => { + const fallbackPercent = Math.max(lastProgressPercent, Math.min(100, Number(progressPercent) || 0)); + const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; + void this.updateProgress(fallbackStage, fallbackPercent, null, statusText || null, processKey, { + contextPatch: { cdLive: buildLiveContext(null) } + }); + }, + ripConfig: { + format, + formatOptions, + selectedTracks: selectedTrackOrder, + tracks: tocTracks, + metadata: selectedMeta, + skipRip, + skipEncode + }, + rawWavDir, + outputDir, + outputTemplate, + isCancelled: () => this.cancelRequestedByJob.has(processKey), + onProcessHandle: bindProcessHandle, + onProgress: (event) => { + handleRipProgress(event).catch((progressError) => { + logger.debug('cd:rip:plugin-progress-update-failed', { + jobId, + error: errorToMeta(progressError) + }); + }); + }, + onLog: async (_level, msg) => { + await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); + }, + context: { jobId: processKey } + }); + cdRipResult = await ripPlugin.rip( + pluginJob || { id: jobId, disc_device: devicePath || null, makemkv_info_json: null }, + pluginCtx + ); + logger.info('plugin:cd:rip:used', { + jobId, + pluginId: ripPlugin.id, + skipRip, + skipEncode, + trackCount: selectedTrackOrder.length + }); + } else { + cdRipResult = await cdRipService.ripAndEncode({ + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + outputTemplate, + selectedTracks: selectedTrackOrder, + tracks: tocTracks, + meta: selectedMeta, + skipRip, + skipEncode, + onProcessHandle: bindProcessHandle, + isCancelled: () => this.cancelRequestedByJob.has(processKey), + onProgress: handleRipProgress, + onLog: async (_level, msg) => { + await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); + }, + context: { jobId: processKey } + }); + } + const { encodeResults: cdEncodeResults = [] } = cdRipResult || {}; settleLifecycle(); if (postScriptIds.length > 0 || postChainIds.length > 0) { @@ -14665,7 +15205,7 @@ class PipelineService extends EventEmitter { outputPath: outputDir || null, rawPath: rawWavDir || null, mediaProfile: 'cd', - pipelineStage: 'CD_ENCODING' + pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING' }); } catch (error) { logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) }); @@ -14683,87 +15223,183 @@ class PipelineService extends EventEmitter { ); } - // RAW-Verzeichnis von Incomplete_ → finalen Namen umbenennen + // RAW-Verzeichnis von Incomplete_ → Zielzustand 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; + if (!skipRip && rawBaseDir && cdMetadataBase) { + try { + const targetRawState = skipEncode ? RAW_FOLDER_STATES.RIP_COMPLETE : RAW_FOLDER_STATES.COMPLETE; + const completedRawDirName = buildRawDirName(cdMetadataBase, jobId, { state: targetRawState }); + 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 bestehendem Namen zugänglich } - } 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, - raw_path: activeRawDir, - output_path: outputDir, - handbrake_info_json: JSON.stringify({ - mode: 'cd_rip', - tracks: cdEncodeResults, - preEncodeScripts: preEncodeScriptsSummary, - postEncodeScripts: postEncodeScriptsSummary - }) - }); + const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso(); + const persistedEncodePlan = { + ...normalizedEncodePlan, + directReencodeReady: true, + directReencodeReadyAt + }; - // Thumbnail aus Cache in persistenten Ordner verschieben - const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); - if (cdPromotedUrl) { - await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); - } + if (skipEncode) { + await historyService.updateJob(jobId, { + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + end_time: null, + rip_successful: 1, + raw_path: activeRawDir, + output_path: null, + encode_input_path: null, + encode_review_confirmed: 0, + encode_plan_json: JSON.stringify(persistedEncodePlan), + handbrake_info_json: JSON.stringify({ + mode: 'cd_rip_raw', + tracks: cdEncodeResults, + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }) + }); - chownRecursive(activeRawDir, rawOwner || outputOwner); - chownRecursive(outputDir, outputOwner); - await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); - historyService.addJobOutputFolder(jobId, outputDir).catch((e) => { - logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message }); - }); - 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); + const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (cdPromotedUrl) { + await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); + } - this._setCdDriveState(devicePath, { - state: 'FINISHED', - jobId, - progress: 100, - eta: null, - statusText: finishedStatusText, - context: { + chownRecursive(activeRawDir, rawOwner || outputOwner); + await historyService.appendLog( jobId, - mediaProfile: 'cd', - tracks: tocTracks, - outputDir, - outputPath: outputDir, - cdRipConfig: normalizedEncodePlan, - cdLive: finishedCdLive, - selectedMetadata: selectedMeta - } - }); - // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED - if (String(devicePath || '').startsWith('__virtual__')) { - this._removeCdDrive(devicePath); - } else { - this._releaseCdDrive(devicePath); - } + 'SYSTEM', + `CD-RAW-Rip abgeschlossen. Laufwerk freigegeben. Encode-Auswahl kann jetzt gestartet werden (RAW: ${activeRawDir}).` + ); - void this.notifyPushover('job_finished', { - title: 'Ripster - CD Rip erfolgreich', - message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` - }); - if (!String(devicePath || '').startsWith('__virtual__')) { - void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + this._releaseDriveLockForJob(jobId, { reason: 'cd_raw_rip_successful' }); + + if (String(devicePath || '').startsWith('__virtual__')) { + this._removeCdDrive(devicePath); + } else { + this._releaseCdDrive(devicePath); + } + + const readyVirtualDrivePath = `__virtual__${jobId}`; + currentPhase = 'encode'; + ripCompletedCount = effectiveTrackTotal; + encodeCompletedCount = 0; + currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; + currentTrackPosition = liveTrackRows[0]?.position || null; + const readyCdLive = buildLiveContext(null); + + this._setCdDriveState(readyVirtualDrivePath, { + state: 'CD_READY_TO_RIP', + jobId, + progress: 0, + eta: null, + statusText: 'RAW-Rip abgeschlossen - Auswahl und Encode bereit', + context: { + jobId, + mediaProfile: 'cd', + devicePath: null, + virtualDrivePath: readyVirtualDrivePath, + skipRip: true, + cdparanoiaCmd, + rawWavDir: activeRawDir, + outputPath: null, + outputTemplate, + tracks: tocTracks, + selectedMetadata: selectedMeta, + cdRipConfig: persistedEncodePlan, + cdLive: readyCdLive + } + }); + this.jobProgress.delete(processKey); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - CD RAW bereit', + message: `Job #${jobId}: RAW-Rip abgeschlossen, Auswahl/Encode bereit` + }); + if (!String(devicePath || '').startsWith('__virtual__')) { + void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + } + } else { + await historyService.updateJob(jobId, { + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + raw_path: activeRawDir, + output_path: outputDir, + handbrake_info_json: JSON.stringify({ + mode: 'cd_rip', + tracks: cdEncodeResults, + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }), + encode_plan_json: JSON.stringify(persistedEncodePlan) + }); + + const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (cdPromotedUrl) { + await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); + } + + chownRecursive(activeRawDir, rawOwner || outputOwner); + if (outputDir) { + chownRecursive(outputDir, outputOwner); + await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); + historyService.addJobOutputFolder(jobId, outputDir).catch((e) => { + logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message }); + }); + } else { + await historyService.appendLog(jobId, 'SYSTEM', 'CD-Rip abgeschlossen.'); + } + + this._releaseDriveLockForJob(jobId, { reason: 'cd_rip_successful' }); + 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); + + this._setCdDriveState(devicePath, { + state: 'FINISHED', + jobId, + progress: 100, + eta: null, + statusText: finishedStatusText, + context: { + jobId, + mediaProfile: 'cd', + tracks: tocTracks, + outputDir, + outputPath: outputDir, + cdRipConfig: persistedEncodePlan, + cdLive: finishedCdLive, + selectedMetadata: selectedMeta + } + }); + // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED + if (String(devicePath || '').startsWith('__virtual__')) { + this._removeCdDrive(devicePath); + } else { + this._releaseCdDrive(devicePath); + } + this.jobProgress.delete(processKey); + + void this.notifyPushover('job_finished', { + title: 'Ripster - CD Rip erfolgreich', + message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` + }); + if (!String(devicePath || '').startsWith('__virtual__')) { + void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + } } } catch (error) { settleLifecycle(); diff --git a/db/schema.sql b/db/schema.sql index 541fda2..797e5e8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -478,26 +478,74 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des 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 ('use_plugin_architecture', 'Erweitert', 'Neue Plugin-Architektur (Beta)', 'boolean', 1, 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.', 'false', '[]', '{}', 9999); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture', 'Erweitert', 'Neue Plugin-Architektur (Beta)', 'boolean', 1, 'Masterschalter: Aktiviert die neue modulare Plugin-Architektur (Phase 2). Alle Medium-Schalter darunter sind nur wirksam wenn dieser aktiv ist.', 'false', '[]', '{}', 9999, NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture', 'false'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('use_plugin_architecture_bluray', 'Erweitert', 'Beta: Blu-ray Plugin aktiv', 'boolean', 1, 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10000); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_bluray', 'Erweitert', 'Blu-ray Plugin', 'boolean', 1, 'Aktiviert das Blu-ray Plugin. Deaktiviert = Legacy-Pfad für alle Blu-ray-Phasen.', 'true', '[]', '{}', 10000, 'use_plugin_architecture'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray', 'true'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('use_plugin_architecture_dvd', 'Erweitert', 'Beta: DVD Plugin aktiv', 'boolean', 1, 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10001); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_bluray_analyze', 'Erweitert', 'Analyse (Blu-ray)', 'boolean', 1, 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', 'true', '[]', '{}', 10001, 'use_plugin_architecture_bluray'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_analyze', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_bluray_rip', 'Erweitert', 'Rip (Blu-ray)', 'boolean', 1, 'MakeMKV-Rip läuft über das Plugin.', 'true', '[]', '{}', 10002, 'use_plugin_architecture_bluray'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_rip', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_bluray_review', 'Erweitert', 'Review / Mediainfo-Prüfung (Blu-ray)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10003, 'use_plugin_architecture_bluray'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_review', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_bluray_encode', 'Erweitert', 'Encoding (Blu-ray)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10004, 'use_plugin_architecture_bluray'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_encode', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_dvd', 'Erweitert', 'DVD Plugin', 'boolean', 1, 'Aktiviert das DVD Plugin. Deaktiviert = Legacy-Pfad für alle DVD-Phasen.', 'true', '[]', '{}', 10010, 'use_plugin_architecture'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd', 'true'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Beta: Audio-CD Plugin aktiv', 'boolean', 1, 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10002); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_dvd_analyze', 'Erweitert', 'Analyse (DVD)', 'boolean', 1, 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', 'true', '[]', '{}', 10011, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_analyze', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_dvd_rip', 'Erweitert', 'Rip (DVD)', 'boolean', 1, 'MakeMKV-Rip läuft über das Plugin.', 'true', '[]', '{}', 10012, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_rip', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_dvd_review', 'Erweitert', 'Review / Mediainfo-Prüfung (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10013, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_review', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_dvd_encode', 'Erweitert', 'Encoding (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10014, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_encode', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Audio-CD Plugin', 'boolean', 1, 'Aktiviert das Audio-CD Plugin. Deaktiviert = Legacy-Pfad für alle CD-Phasen.', 'true', '[]', '{}', 10020, 'use_plugin_architecture'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd', 'true'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('use_plugin_architecture_audiobook', 'Erweitert', 'Beta: Audiobook Plugin aktiv', 'boolean', 1, 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10003); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_cd_analyze', 'Erweitert', 'Analyse (Audio-CD)', 'boolean', 1, 'TOC-Auslesung läuft über das Plugin.', 'true', '[]', '{}', 10021, 'use_plugin_architecture_cd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd_analyze', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_cd_rip', 'Erweitert', 'Rip (Audio-CD)', 'boolean', 1, 'CD-Rip/RAW-Rip/Encode-aus-RAW läuft über das Plugin.', 'true', '[]', '{}', 10022, 'use_plugin_architecture_cd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd_rip', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_audiobook', 'Erweitert', 'Audiobook Plugin', 'boolean', 1, 'Aktiviert das Audiobook Plugin. Deaktiviert = Legacy-Pfad für alle Audiobook-Phasen.', 'true', '[]', '{}', 10030, 'use_plugin_architecture'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook', 'true'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_audiobook_analyze', 'Erweitert', 'Analyse (Audiobook)', 'boolean', 1, 'ffprobe-Metadatenanalyse läuft über das Plugin.', 'true', '[]', '{}', 10031, 'use_plugin_architecture_audiobook'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook_analyze', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('use_plugin_architecture_audiobook_encode', 'Erweitert', 'Encoding (Audiobook)', 'boolean', 1, 'Noch nicht implementiert — Encoding läuft noch über Legacy.', 'false', '[]', '{"readonly":true}', 10032, 'use_plugin_architecture_audiobook'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook_encode', '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/package-lock.json b/frontend/package-lock.json index 3f6089f..e1eab88 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-1", + "version": "0.12.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-1", + "version": "0.12.0-2", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 4a3ec05..408542f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-1", + "version": "0.12.0-2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index c259462..0e5687b 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -742,16 +742,23 @@ export const api = { }, async deleteJobEntry(jobId, target = 'none', options = {}) { const includeRelated = Boolean(options?.includeRelated); + const resetDriveState = Boolean(options?.resetDriveState); const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths .map((item) => String(item || '').trim()) .filter(Boolean) : null; + const hasKeepDetectedDevice = options?.keepDetectedDevice !== undefined; + const keepDetectedDevice = hasKeepDetectedDevice + ? Boolean(options.keepDetectedDevice) + : null; const result = await request(`/history/${jobId}/delete`, { method: 'POST', body: JSON.stringify({ target, includeRelated, + resetDriveState, + ...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}), ...(selectedMoviePaths ? { selectedMoviePaths } : {}) }) }); diff --git a/frontend/src/components/AudiobookConfigPanel.jsx b/frontend/src/components/AudiobookConfigPanel.jsx index 6340ea4..6713ff6 100644 --- a/frontend/src/components/AudiobookConfigPanel.jsx +++ b/frontend/src/components/AudiobookConfigPanel.jsx @@ -129,6 +129,7 @@ export default function AudiobookConfigPanel({ onStart, onCancel, onRetry, + onDeleteJob, busy }) { const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; @@ -353,6 +354,17 @@ export default function AudiobookConfigPanel({ /> ) : null} + {jobId ? ( +