From e99cdf1895f9e9ff43f224977f31c2e61b17e069 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sat, 21 Mar 2026 15:58:57 +0000 Subject: [PATCH] 0.12.0-3 Plugin Integration --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/settingsRoutes.js | 6 +- backend/src/services/diskDetectionService.js | 116 ++++++++++- backend/src/services/historyService.js | 90 +++++++- backend/src/services/pipelineService.js | 192 +++++++++++++++++- backend/src/services/settingsService.js | 60 ++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 125 +++++++++++- frontend/src/api/client.js | 1 - frontend/src/components/CdRipConfigPanel.jsx | 4 +- .../src/components/DynamicSettingsForm.jsx | 37 +++- frontend/src/components/JobDetailDialog.jsx | 9 +- frontend/src/pages/DashboardPage.jsx | 159 +++++++++++++-- frontend/src/pages/HistoryPage.jsx | 36 +++- frontend/src/styles/app.css | 107 ++++++++-- frontend/src/utils/statusPresentation.js | 77 ++++++- package-lock.json | 4 +- package.json | 8 +- 20 files changed, 928 insertions(+), 115 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index bcbd061..c1302ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-2", + "version": "0.12.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-2", + "version": "0.12.0-3", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 60e05ea..d923591 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-2", + "version": "0.12.0-3", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index ebfe4c7..5503496 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -454,8 +454,10 @@ router.get( .map((d) => { const name = String(d.name || ''); const devicePath = d.path || (name ? `/dev/${name}` : null); - const match = name.match(/sr(\d+)$/); - const discIndex = match ? Number(match[1]) : null; + const resolvedIndex = Number(d.makemkvIndex); + const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0 + ? Math.trunc(resolvedIndex) + : null; return { path: devicePath, name, diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index aa59114..be5b6bf 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -52,6 +52,40 @@ function flattenDevices(nodes, acc = []) { return acc; } +function normalizeOpticalDevicePath(entry) { + const directPath = String(entry?.path || '').trim(); + if (directPath) { + return directPath; + } + const name = String(entry?.name || '').trim(); + return name ? `/dev/${name}` : ''; +} + +function compareOpticalDevicePaths(left, right) { + return String(left || '').localeCompare(String(right || ''), undefined, { + numeric: true, + sensitivity: 'base' + }); +} + +function buildMakeMkvIndexByDevicePath(entries = []) { + const candidates = (Array.isArray(entries) ? entries : []) + .filter((entry) => String(entry?.type || '').trim() === 'rom') + .map((entry) => normalizeOpticalDevicePath(entry)) + .filter(Boolean); + const sortedUniquePaths = Array.from(new Set(candidates)).sort(compareOpticalDevicePaths); + const map = new Map(); + for (let index = 0; index < sortedUniquePaths.length; index += 1) { + const devicePath = sortedUniquePaths[index]; + map.set(devicePath, index); + const devName = devicePath.startsWith('/dev/') ? devicePath.slice(5) : ''; + if (devName) { + map.set(devName, index); + } + } + return map; +} + function buildSignature(info) { return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`; } @@ -187,10 +221,13 @@ function inferMediaProfileFromUdevProperties(properties = {}) { return parsed; }; - const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1'); - const hasBD = hasFlag('ID_CDROM_MEDIA_BD'); - const hasDVD = hasFlag('ID_CDROM_MEDIA_DVD'); - const hasCD = hasFlag('ID_CDROM_MEDIA_CD'); + const hasExactFlag = (key) => flags[String(key || '').trim().toUpperCase()] === '1'; + // Only use exact media-presence keys here. Prefix matching would also catch + // drive capability flags (e.g. ID_CDROM_MEDIA_BD_R=1) and misclassify DVDs + // in BD-capable drives as Blu-ray media. + const hasBD = hasExactFlag('ID_CDROM_MEDIA_BD'); + const hasDVD = hasExactFlag('ID_CDROM_MEDIA_DVD'); + const hasCD = hasExactFlag('ID_CDROM_MEDIA_CD'); const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO); const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA); const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0; @@ -395,6 +432,28 @@ class DiskDetectionService extends EventEmitter { }); } + forceUnlockDevice(devicePath, options = {}) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return 0; + } + + const entry = this.deviceLocks.get(normalized); + if (!entry) { + return 0; + } + + const previousCount = Math.max(0, Number(entry.count) || 0); + this.deviceLocks.delete(normalized); + logger.warn('lock:force-remove', { + devicePath: normalized, + previousCount, + reason: String(options?.reason || '').trim() || null, + deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : [] + }); + return previousCount; + } + isDeviceLocked(devicePath) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { @@ -666,7 +725,13 @@ class DiskDetectionService extends EventEmitter { } const details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + const inferredIndex = Number( + makemkvIndexByPath.get(devicePath) + ?? makemkvIndexByPath.get(match.name || '') + ?? match.makemkvIndex + ); // Always call checkMediaPresent to get the filesystem type (needed for accurate // mediaProfile detection). Use lsblk SIZE as fallback presence indicator for @@ -708,7 +773,9 @@ class DiskDetectionService extends EventEmitter { mountpoint: match.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, - index: this.guessDiscIndex(match.name || devicePath) + index: Number.isFinite(inferredIndex) && inferredIndex >= 0 + ? Math.trunc(inferredIndex) + : this.guessDiscIndex(match.name || devicePath) }; logger.debug('detect:explicit:success', { detected }); return detected; @@ -716,6 +783,7 @@ class DiskDetectionService extends EventEmitter { async detectAuto() { const details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); const romCandidates = details.filter((entry) => entry.type === 'rom'); for (const item of romCandidates) { @@ -751,6 +819,11 @@ class DiskDetectionService extends EventEmitter { fstype: detectedFsType, mountpoint: item.mountpoint }); + const detectedIndex = Number( + makemkvIndexByPath.get(path) + ?? makemkvIndexByPath.get(item.name || '') + ?? item.makemkvIndex + ); const detected = { mode: 'auto', @@ -762,7 +835,9 @@ class DiskDetectionService extends EventEmitter { mountpoint: item.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, - index: this.guessDiscIndex(item.name) + index: Number.isFinite(detectedIndex) && detectedIndex >= 0 + ? Math.trunc(detectedIndex) + : this.guessDiscIndex(item.name) }; logger.debug('detect:auto:success', { detected }); return detected; @@ -774,6 +849,7 @@ class DiskDetectionService extends EventEmitter { async detectAllAuto() { const details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); const romCandidates = details.filter((entry) => entry.type === 'rom'); const results = []; @@ -818,6 +894,11 @@ class DiskDetectionService extends EventEmitter { fstype: detectedFsType, mountpoint: item.mountpoint }); + const detectedIndex = Number( + makemkvIndexByPath.get(path) + ?? makemkvIndexByPath.get(item.name || '') + ?? item.makemkvIndex + ); const detected = { mode: 'auto', @@ -829,7 +910,9 @@ class DiskDetectionService extends EventEmitter { mountpoint: item.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, - index: this.guessDiscIndex(item.name) + index: Number.isFinite(detectedIndex) && detectedIndex >= 0 + ? Math.trunc(detectedIndex) + : this.guessDiscIndex(item.name) }; logger.debug('detect:all-auto:found', { detected }); results.push(detected); @@ -858,8 +941,25 @@ class DiskDetectionService extends EventEmitter { model: entry.model, sizeBytes: Number(entry.size) || 0 })); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(devices); + const withMakeMkvIndex = devices.map((entry) => { + if (entry.type !== 'rom') { + return { ...entry, makemkvIndex: null }; + } + const devicePath = normalizeOpticalDevicePath(entry); + const inferredIndex = Number( + makemkvIndexByPath.get(devicePath) + ?? makemkvIndexByPath.get(entry.name || '') + ); + return { + ...entry, + makemkvIndex: Number.isFinite(inferredIndex) && inferredIndex >= 0 + ? Math.trunc(inferredIndex) + : null + }; + }); logger.debug('lsblk:ok', { deviceCount: devices.length }); - return devices; + return withMakeMkvIndex; } catch (error) { logger.warn('lsblk:failed', { error: errorToMeta(error) }); return []; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 665b9ae..21cae6e 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4059,10 +4059,25 @@ class HistoryService { } } + const hasTrackedOutputSource = (sources = []) => { + const normalizedSources = Array.isArray(sources) + ? sources + .map((source) => String(source || '').trim().toLowerCase()) + .filter(Boolean) + : []; + return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path'); + }; + const buildList = (target) => Array.from(candidateMap.values()) .filter((row) => row.target === target) .map((row) => { const inspection = inspectDeletionPath(row.path); + const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); + // Do not expose stale output file paths from DB/lineage in delete preview. + // They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks. + if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) { + return null; + } return { target, path: row.path, @@ -4070,9 +4085,10 @@ class HistoryService { 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)) + sources }; }) + .filter(Boolean) .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); return { @@ -4289,6 +4305,8 @@ class HistoryService { raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }, movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null } }; + let movieDeletedPathForTracking = null; + let movieCandidatePathForTracking = null; if (target === 'raw' || target === 'both') { summary.raw.attempted = true; @@ -4328,7 +4346,59 @@ class HistoryService { error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveOutputPath)) { - summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; + const movieRoot = normalizeComparablePath(effectiveMovieDir); + const trackedFolders = await this.getJobOutputFolders(jobId); + const trackedExistingPaths = Array.from(new Set( + trackedFolders + .map((row) => normalizeComparablePath(row?.output_path)) + .filter(Boolean) + .filter((candidatePath) => isPathInside(effectiveMovieDir, candidatePath)) + .filter((candidatePath) => candidatePath !== movieRoot) + .filter((candidatePath) => fs.existsSync(candidatePath)) + )); + + if (trackedExistingPaths.length === 1) { + const trackedPath = trackedExistingPaths[0]; + const stat = fs.lstatSync(trackedPath); + if (stat.isDirectory()) { + const keepRoot = trackedPath === movieRoot; + const result = deleteFilesRecursively(trackedPath, keepRoot); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = trackedPath; + movieCandidatePathForTracking = trackedPath; + summary.movie.reason = null; + } else { + const parentDir = normalizeComparablePath(path.dirname(trackedPath)); + const canDeleteParentDir = parentDir + && parentDir !== movieRoot + && isPathInside(movieRoot, parentDir) + && fs.existsSync(parentDir) + && fs.lstatSync(parentDir).isDirectory(); + + if (canDeleteParentDir) { + const result = deleteFilesRecursively(parentDir, false); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = trackedPath; + } else { + fs.unlinkSync(trackedPath); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = 0; + movieDeletedPathForTracking = trackedPath; + movieCandidatePathForTracking = trackedPath; + } + summary.movie.reason = null; + } + } else if (trackedExistingPaths.length > 1) { + summary.movie.reason = 'Mehrere bekannte Output-Ordner gefunden. Bitte gezielt über die Ordnerauswahl löschen.'; + } else { + summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; + } } else { const outputPath = normalizeComparablePath(effectiveOutputPath); const movieRoot = normalizeComparablePath(effectiveMovieDir); @@ -4339,6 +4409,8 @@ class HistoryService { summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = outputPath; + movieCandidatePathForTracking = outputPath; } else { const parentDir = normalizeComparablePath(path.dirname(outputPath)); const canDeleteParentDir = parentDir @@ -4352,19 +4424,29 @@ class HistoryService { summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = outputPath; } else { fs.unlinkSync(outputPath); summary.movie.deleted = true; summary.movie.filesDeleted = 1; summary.movie.dirsRemoved = 0; + movieDeletedPathForTracking = outputPath; + movieCandidatePathForTracking = outputPath; } } } } // Remove deleted movie paths from output folders tracking - if (summary.movie?.deleted && effectiveOutputPath) { - await this.removeJobOutputFolder(jobId, effectiveOutputPath); + if (summary.movie?.deleted) { + const trackingCleanupPaths = Array.from(new Set([ + movieCandidatePathForTracking, + movieDeletedPathForTracking + ].filter(Boolean))); + for (const candidatePath of trackingCleanupPaths) { + await this.removeJobOutputFolder(jobId, candidatePath); + } } await this.appendLog( diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 1c62e65..aac22e6 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -4900,16 +4900,21 @@ class PipelineService extends EventEmitter { : (existing?.device && typeof existing.device === 'object' ? existing.device : { path: normalizedPath, mediaProfile: 'cd' }); + const releasedDevice = { + ...fallbackDevice, + path: normalizedPath, + mediaProfile: 'cd' + }; this._setCdDriveState(normalizedPath, { state: 'DISC_DETECTED', jobId: null, - device: fallbackDevice, + device: releasedDevice, progress: 0, eta: null, statusText: null, context: { - device: fallbackDevice, + device: releasedDevice, devicePath: normalizedPath, mediaProfile: 'cd' } @@ -5143,6 +5148,149 @@ class PipelineService extends EventEmitter { return deleted; } + _forceUnlockDriveLocksForDeletedJobs(devicePaths = [], deletedJobIds = []) { + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { + return []; + } + + const deletedSet = new Set( + (Array.isArray(deletedJobIds) ? deletedJobIds : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const activeLocks = diskDetectionService.getActiveLocks(); + const forceUnlockedPaths = []; + + for (const devicePath of normalizedPaths) { + const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null; + if (!lock) { + continue; + } + const owners = Array.isArray(lock?.owners) ? lock.owners : []; + const ownerJobIds = owners + .map((owner) => Number(owner?.jobId)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + const hasForeignOwner = ownerJobIds.some((jobId) => !deletedSet.has(jobId)); + if (hasForeignOwner) { + logger.warn('drive-lock:force-unlock:skipped-foreign-owner', { + devicePath, + ownerJobIds + }); + continue; + } + const clearedCount = Number(diskDetectionService.forceUnlockDevice(devicePath, { + reason: 'job_deleted', + deletedJobIds: Array.from(deletedSet) + }) || 0); + if (clearedCount > 0) { + forceUnlockedPaths.push(devicePath); + } + } + + if (forceUnlockedPaths.length > 0) { + logger.warn('drive-lock:force-unlocked-after-delete', { + devicePaths: forceUnlockedPaths + }); + } + return forceUnlockedPaths; + } + + async _rescanDrivesAfterDelete(devicePaths = []) { + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (normalizedPaths.length === 0) { + return; + } + + const retryDelaysMs = [0, 450, 1250]; + for (const devicePath of normalizedPaths) { + let lastResult = null; + for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) { + const waitMs = retryDelaysMs[attempt]; + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + try { + lastResult = await diskDetectionService.rescanDriveAndEmit(devicePath); + } catch (error) { + logger.warn('job-delete:drive-rescan:failed', { + devicePath, + attempt: attempt + 1, + error: errorToMeta(error) + }); + continue; + } + + const detectedProfile = String(lastResult?.device?.mediaProfile || '').trim().toLowerCase(); + const present = Boolean(lastResult?.present); + const locked = Boolean(lastResult?.locked); + if (locked) { + continue; + } + if (!present) { + continue; + } + if (detectedProfile && detectedProfile !== 'other' && detectedProfile !== 'unknown') { + break; + } + } + logger.info('job-delete:drive-rescan:done', { + devicePath, + emitted: lastResult?.emitted || 'none', + present: Boolean(lastResult?.present), + mediaProfile: lastResult?.device?.mediaProfile || null, + locked: Boolean(lastResult?.locked) + }); + } + } + + _forceStopActiveProcessForJob(jobId, options = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + + const targetJobId = Math.trunc(normalizedJobId); + const processHandle = this.activeProcesses.get(targetJobId) || null; + if (!processHandle) { + return false; + } + + const childPid = Number(processHandle?.child?.pid); + try { + processHandle?.cancel?.(); + } catch (_error) { + // ignore cancellation race errors + } + + if (Number.isFinite(childPid) && childPid > 0) { + try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + } + try { + processHandle?.child?.kill?.('SIGKILL'); + } catch (_error) { + // noop + } + + logger.warn('job-delete:active-process-killed', { + jobId: targetJobId, + reason: String(options?.reason || '').trim() || null, + pid: Number.isFinite(childPid) && childPid > 0 ? childPid : null + }); + return true; + } + async onJobsDeleted(jobIds = [], options = {}) { const normalizedIds = Array.isArray(jobIds) ? jobIds @@ -5167,7 +5315,8 @@ class PipelineService extends EventEmitter { const removedQueueEntries = previousQueueLength - this.queueEntries.length; for (const jobId of normalizedIds) { - this.cancelRequestedByJob.delete(jobId); + this.cancelRequestedByJob.add(jobId); + this._forceStopActiveProcessForJob(jobId, { reason: 'job_deleted' }); const lockForJob = this._getDriveLockByJobId(jobId); if (lockForJob?.devicePath) { affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath)); @@ -5190,6 +5339,14 @@ class PipelineService extends EventEmitter { } this.activeProcesses.delete(jobId); } + // Keep the cancellation marker briefly so any late async step in the old + // pipeline thread aborts before spawning follow-up processes. + for (const jobId of normalizedIds) { + setTimeout(() => { + this.cancelRequestedByJob.delete(jobId); + }, 120000); + } + const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds); let driveResetChanged = false; if (resetDriveState) { @@ -5222,6 +5379,10 @@ class PipelineService extends EventEmitter { await this.emitQueueChanged(); void this.pumpQueue(); } + + if (resetDriveState || forceUnlockedPaths.length > 0) { + void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths)); + } return { cleaned: normalizedIds.length, removedQueueEntries @@ -12780,8 +12941,9 @@ class PipelineService extends EventEmitter { applyLine(line, true); } }); - - this.activeProcesses.set(Number(normalizedJobId), processHandle); + const normalizedProcessKey = Number(normalizedJobId); + const previousProcessHandle = this.activeProcesses.get(normalizedProcessKey) || null; + this.activeProcesses.set(normalizedProcessKey, processHandle); this.syncPrimaryActiveProcess(); try { @@ -12814,8 +12976,18 @@ class PipelineService extends EventEmitter { logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) }); throw error; } finally { - this.activeProcesses.delete(Number(normalizedJobId)); - this.cancelRequestedByJob.delete(Number(normalizedJobId)); + const activeHandleForJob = this.activeProcesses.get(normalizedProcessKey) || null; + if (activeHandleForJob === processHandle) { + if (previousProcessHandle && previousProcessHandle !== processHandle) { + this.activeProcesses.set(normalizedProcessKey, previousProcessHandle); + } else { + this.activeProcesses.delete(normalizedProcessKey); + } + } + // Preserve cancellation marker for outer/orchestrating handles (e.g. CD shared handle). + if (!(previousProcessHandle && previousProcessHandle !== processHandle)) { + this.cancelRequestedByJob.delete(normalizedProcessKey); + } this.syncPrimaryActiveProcess(); await historyService.closeProcessLog(jobId); await this.emitQueueChanged(); @@ -14963,6 +15135,8 @@ class PipelineService extends EventEmitter { } } }; + let currentTrackPosition = null; + let buildLiveContext = () => null; this.activeProcesses.set(processKey, sharedHandle); this.syncPrimaryActiveProcess(); @@ -14995,8 +15169,8 @@ class PipelineService extends EventEmitter { let encodeCompletedCount = 0; let currentPhase = 'rip'; let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; - let currentTrackPosition = liveTrackRows[0]?.position || null; - const buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({ + currentTrackPosition = liveTrackRows[0]?.position || null; + buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({ trackRows: liveTrackRows, phase: currentPhase, trackIndex: currentTrackIndex, diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 0e51178..e68e998 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -576,7 +576,8 @@ function mapPresetEntriesToOptions(entries) { * Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}. * Supports both the legacy string format ["/dev/sr0"] and the object format * [{"path": "/dev/sr0", "makemkvIndex": 0}]. - * When makemkvIndex is missing, it is auto-derived from the device name (sr0→0, sr1→1). + * When makemkvIndex is missing, it is assigned by list order (0,1,2,...) so + * sparse kernel names like /dev/sr2 still map to contiguous MakeMKV indices. */ function parseDriveDeviceEntries(raw) { try { @@ -584,28 +585,49 @@ function parseDriveDeviceEntries(raw) { if (!Array.isArray(arr)) { return []; } - return arr.map((entry) => { + const normalized = arr.map((entry) => { if (typeof entry === 'string') { const p = entry.trim(); if (!p) { return null; } - const m = p.match(/sr(\d+)$/); - return { path: p, makemkvIndex: m ? Number(m[1]) : 0 }; + return { path: p, makemkvIndex: null }; } if (entry && typeof entry === 'object' && entry.path) { const p = String(entry.path || '').trim(); if (!p) { return null; } - const idxRaw = entry.makemkvIndex != null ? Number(entry.makemkvIndex) : null; - const m = p.match(/sr(\d+)$/); - const derived = m ? Number(m[1]) : 0; - const idx = Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : derived; - return { path: p, makemkvIndex: idx }; + const idxRaw = Number(entry.makemkvIndex); + return { + path: p, + makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null + }; } return null; }).filter(Boolean); + + const used = new Set(); + let nextAutoIndex = 0; + return normalized.map((entry) => { + if (entry.makemkvIndex != null) { + used.add(entry.makemkvIndex); + if (entry.makemkvIndex >= nextAutoIndex) { + nextAutoIndex = entry.makemkvIndex + 1; + } + return entry; + } + while (used.has(nextAutoIndex)) { + nextAutoIndex += 1; + } + const resolved = { + path: entry.path, + makemkvIndex: nextAutoIndex + }; + used.add(nextAutoIndex); + nextAutoIndex += 1; + return resolved; + }); } catch (_error) { return []; } @@ -1413,17 +1435,31 @@ class SettingsService { resolveSourceArg(map, deviceInfo = null) { const devicePath = String(deviceInfo?.path || '').trim(); + const deviceIndex = Number(deviceInfo?.makemkvIndex ?? deviceInfo?.index); + const configuredEntries = parseDriveDeviceEntries(map.drive_devices); // In explicit mode: look up per-drive makemkvIndex from drive_devices if (devicePath && map.drive_mode === 'explicit') { - const entries = parseDriveDeviceEntries(map.drive_devices); - const entry = entries.find((e) => e.path === devicePath); + const entry = configuredEntries.find((e) => e.path === devicePath); if (entry) { return `disc:${entry.makemkvIndex}`; } } - // Auto-derive from device name (/dev/sr0 → disc:0, /dev/sr1 → disc:1) + // Prefer configured per-drive index when a path match exists (also in auto mode). + if (devicePath) { + const configured = configuredEntries.find((e) => e.path === devicePath); + if (configured) { + return `disc:${configured.makemkvIndex}`; + } + } + + // Prefer device-provided MakeMKV index from disk detection service. + if (Number.isFinite(deviceIndex) && deviceIndex >= 0) { + return `disc:${Math.trunc(deviceIndex)}`; + } + + // Last automatic fallback: derive from device name (/dev/sr0 → disc:0). if (devicePath) { const match = devicePath.match(/sr(\d+)$/); if (match) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1eab88..c7245d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-2", + "version": "0.12.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-2", + "version": "0.12.0-3", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 408542f..db085c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-2", + "version": "0.12.0-3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2af43d5..4dfccfb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,15 @@ function clampPercent(value) { return Math.max(0, Math.min(100, parsed)); } +function normalizeStage(value) { + return String(value || '').trim().toUpperCase(); +} + +function isTerminalStage(value) { + const normalized = normalizeStage(value); + return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; +} + function formatBytes(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) { @@ -297,8 +306,49 @@ function App() { : null; setPipeline((prev) => { const next = { ...prev }; + const normalizedProgressJobId = normalizeJobId(progressJobId); + const progressStage = normalizeStage(payload?.state); + const isCdProgressStage = progressStage === 'CD_ANALYZING' + || progressStage === 'CD_RIPPING' + || progressStage === 'CD_ENCODING'; + const incomingIsTerminal = isTerminalStage(progressStage); + const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId); + const prevJobProgress = normalizedProgressJobId + ? (prev?.jobProgress?.[normalizedProgressJobId] || null) + : null; + const prevJobProgressState = normalizeStage(prevJobProgress?.state); + const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState); + const matchingCdDriveEntry = normalizedProgressJobId + ? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId) + : null; + const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state); + const hasKnownBinding = Boolean( + normalizedProgressJobId + && ( + prevActiveJobId === normalizedProgressJobId + || prevJobProgress + || matchingCdDriveEntry + ) + ); + + // Ignore late/stale progress packets that arrive after a terminal state + // or for jobs that are no longer bound in pipeline state. + if ( + normalizedProgressJobId + && !incomingIsTerminal + && ( + prevJobProgressIsTerminal + || matchingCdDriveIsTerminal + || !hasKnownBinding + ) + ) { + return prev; + } + if (progressJobId != null) { const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; + const previousJobStage = normalizeStage(previousJobProgress?.state); + const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal; const mergedJobContext = contextPatch ? { ...(previousJobProgress?.context && typeof previousJobProgress.context === 'object' @@ -313,19 +363,21 @@ function App() { ...(prev?.jobProgress || {}), [progressJobId]: { ...previousJobProgress, - state: payload.state, - progress: payload.progress, - eta: payload.eta, - statusText: payload.statusText, + state: keepPreviousJobStage ? previousJobProgress.state : payload.state, + progress: keepPreviousJobStage ? previousJobProgress.progress : payload.progress, + eta: keepPreviousJobStage ? previousJobProgress.eta : payload.eta, + statusText: keepPreviousJobStage ? previousJobProgress.statusText : payload.statusText, ...(mergedJobContext !== undefined ? { context: mergedJobContext } : {}) } }; } if (progressJobId === prev?.activeJobId || progressJobId == null) { - next.state = payload.state ?? prev?.state; - next.progress = payload.progress ?? prev?.progress; - next.eta = payload.eta ?? prev?.eta; - next.statusText = payload.statusText ?? prev?.statusText; + const previousGlobalStage = normalizeStage(prev?.state); + const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal; + next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state); + next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress); + next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta); + next.statusText = keepPreviousGlobalStage ? prev?.statusText : (payload.statusText ?? prev?.statusText); if (contextPatch) { next.context = { ...(prev?.context && typeof prev.context === 'object' ? prev.context : {}), @@ -333,6 +385,63 @@ function App() { }; } } + + // Keep per-drive CD progress in sync with live progress events. + // Backend sends frequent PIPELINE_PROGRESS updates, while cdDrives + // snapshots are only broadcast on state transitions. + if (isCdProgressStage && prev?.cdDrives && typeof prev.cdDrives === 'object') { + const cdDrivesEntries = Object.entries(prev.cdDrives); + const nextCdDrives = { ...prev.cdDrives }; + const patchDrive = (driveState) => { + const currentDriveStage = normalizeStage(driveState?.state); + if (isTerminalStage(currentDriveStage) && !incomingIsTerminal) { + return driveState; + } + const mergedContext = contextPatch + ? { + ...(driveState?.context && typeof driveState.context === 'object' ? driveState.context : {}), + ...contextPatch + } + : driveState?.context; + return { + ...driveState, + state: payload?.state ?? driveState?.state, + progress: payload?.progress ?? driveState?.progress, + eta: payload?.eta ?? driveState?.eta, + statusText: payload?.statusText ?? driveState?.statusText, + ...(mergedContext !== undefined ? { context: mergedContext } : {}) + }; + }; + + let updated = false; + if (normalizedProgressJobId) { + for (const [drivePath, driveState] of cdDrivesEntries) { + if (normalizeJobId(driveState?.jobId) === normalizedProgressJobId) { + nextCdDrives[drivePath] = patchDrive(driveState); + updated = true; + } + } + } + + if (!updated) { + const activeCdEntries = cdDrivesEntries.filter(([, driveState]) => { + const driveStage = String(driveState?.state || '').trim().toUpperCase(); + return driveStage === 'CD_ANALYZING' + || driveStage === 'CD_RIPPING' + || driveStage === 'CD_ENCODING'; + }); + if (activeCdEntries.length === 1) { + const [drivePath, driveState] = activeCdEntries[0]; + nextCdDrives[drivePath] = patchDrive(driveState); + updated = true; + } + } + + if (updated) { + next.cdDrives = nextCdDrives; + } + } + return next; }); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 0e5687b..313ea6a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -826,7 +826,6 @@ export const api = { forceRefresh: options.forceRefresh }); }, - // ── User Presets ─────────────────────────────────────────────────────────── getUserPresets(mediaType = null, options = {}) { const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : ''; diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index e19347c..b96acde 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -731,6 +731,7 @@ export default function CdRipConfigPanel({ const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length; const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition); const lastState = String(context?.lastState || '').trim().toUpperCase(); + const lastStateLabel = getStatusLabel(lastState); const failureStage = String(context?.stage || '').trim().toUpperCase(); const normalizedLivePhase = String(cdLive?.phase || '').trim().toLowerCase(); const normalizedStatusText = String(statusText || '').trim().toUpperCase(); @@ -897,12 +898,11 @@ export default function CdRipConfigPanel({
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} + {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} {jobId ?
Job-ID: #{jobId}
: null} diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index c651567..4d9057f 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -94,16 +94,40 @@ function DriveDevicesEditor({ value, onChange, settingKey }) { try { const parsed = JSON.parse(val || '[]'); if (!Array.isArray(parsed)) return []; - return parsed.map((e) => { + const normalized = parsed.map((e) => { if (typeof e === 'string') { const p = e.trim(); - const m = p.match(/sr(\d+)$/); - return { path: p, makemkvIndex: m ? Number(m[1]) : 0 }; + if (!p) return null; + return { path: p, makemkvIndex: null }; } if (e && typeof e === 'object') { - return { path: String(e.path || '').trim(), makemkvIndex: Number.isFinite(Number(e.makemkvIndex)) ? Number(e.makemkvIndex) : 0 }; + const p = String(e.path || '').trim(); + if (!p) return null; + const idxRaw = Number(e.makemkvIndex); + return { + path: p, + makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null + }; } - return { path: '', makemkvIndex: 0 }; + return null; + }).filter(Boolean); + const used = new Set(); + let nextAutoIndex = 0; + return normalized.map((entry) => { + if (entry.makemkvIndex != null) { + used.add(entry.makemkvIndex); + if (entry.makemkvIndex >= nextAutoIndex) { + nextAutoIndex = entry.makemkvIndex + 1; + } + return entry; + } + while (used.has(nextAutoIndex)) { + nextAutoIndex += 1; + } + const resolved = { path: entry.path, makemkvIndex: nextAutoIndex }; + used.add(nextAutoIndex); + nextAutoIndex += 1; + return resolved; }); } catch (_e) { return []; @@ -129,8 +153,7 @@ function DriveDevicesEditor({ value, onChange, settingKey }) { onChange={(e) => { const next = [...devices]; const p = e.target.value; - const m = p.match(/sr(\d+)$/); - next[idx] = { ...next[idx], path: p, makemkvIndex: m ? Number(m[1]) : next[idx].makemkvIndex }; + next[idx] = { ...next[idx], path: p }; updateDevices(next); }} className="drive-device-input" diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 4fe8a14..0b8b288 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -6,7 +6,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; import { resolveJobPluginExecution } from '../utils/pluginExecution'; -import { getStatusLabel } from '../utils/statusPresentation'; +import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation'; const CD_FORMAT_LABELS = { flac: 'FLAC', @@ -26,9 +26,10 @@ function JsonView({ title, value }) { } function ScriptResultRow({ result }) { - const status = String(result?.status || '').toUpperCase(); - const isSuccess = status === 'SUCCESS'; - const isError = status === 'ERROR'; + const statusCode = String(result?.status || '').toUpperCase(); + const status = getProcessStatusLabel(statusCode); + const isSuccess = statusCode === 'SUCCESS'; + const isError = statusCode === 'ERROR'; const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle'; const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning'; return ( diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index ba7b7dc..72945e3 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -197,6 +197,40 @@ function runtimeOutcomeMeta(outcome, status) { return runtimeStatusMeta(status); } +function driveStateIconMeta(stateLabel) { + const normalized = String(stateLabel || '').trim().toUpperCase(); + if (!normalized || normalized === 'LEER' || normalized === 'IDLE') { + return { icon: 'pi-stop', spin: false }; + } + if (normalized === 'DISC_DETECTED') { + return { icon: 'pi-check', spin: false }; + } + if (normalized === 'FINISHED') { + return { icon: 'pi-check-circle', spin: false }; + } + if (normalized === 'ERROR' || normalized === 'CANCELLED') { + return { icon: 'pi-times-circle', spin: false }; + } + if (processingStates.includes(normalized)) { + return { icon: 'pi-spinner', spin: true }; + } + if ( + normalized === 'METADATA_SELECTION' + || normalized === 'CD_METADATA_SELECTION' + || normalized === 'WAITING_FOR_USER_DECISION' + ) { + return { icon: 'pi-list', spin: false }; + } + if ( + normalized === 'READY_TO_START' + || normalized === 'READY_TO_ENCODE' + || normalized === 'CD_READY_TO_RIP' + ) { + return { icon: 'pi-play', spin: false }; + } + return { icon: 'pi-question-circle', spin: false }; +} + function hasRuntimeOutputDetails(item) { if (!item || typeof item !== 'object') { return false; @@ -304,6 +338,20 @@ function normalizeQueue(queue) { }; } +function buildCdDriveLifecycleKey(cdDrives) { + if (!cdDrives || typeof cdDrives !== 'object') { + return ''; + } + const entries = Object.entries(cdDrives) + .map(([devicePath, driveState]) => { + const state = String(driveState?.state || '').trim().toUpperCase(); + const jobId = normalizeJobId(driveState?.jobId || driveState?.context?.jobId) || 0; + return `${devicePath}|${jobId}|${state}`; + }) + .sort(); + return entries.join('::'); +} + function getQueueActionResult(response) { return response?.result && typeof response.result === 'object' ? response.result : {}; } @@ -917,6 +965,10 @@ export default function DashboardPage({ () => normalizeHardwareMonitoringPayload(hardwareMonitoring), [hardwareMonitoring] ); + const cdDriveLifecycleKey = useMemo( + () => buildCdDriveLifecycleKey(pipeline?.cdDrives), + [pipeline?.cdDrives] + ); const monitoringSample = monitoringState.sample; const cpuMetrics = monitoringSample?.cpu || null; const memoryMetrics = monitoringSample?.memory || null; @@ -1115,13 +1167,43 @@ export default function DashboardPage({ } }, [pipeline?.cdDrives]); + useEffect(() => { + if (!cdMetadataDialogVisible) { + return; + } + const dialogJobId = normalizeJobId(cdMetadataDialogContext?.jobId); + if (!dialogJobId) { + return; + } + const cdDrives = pipeline?.cdDrives; + if (!cdDrives || typeof cdDrives !== 'object') { + return; + } + + let stillInMetadataSelection = false; + for (const drive of Object.values(cdDrives)) { + const driveState = String(drive?.state || '').trim().toUpperCase(); + const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null; + const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId); + if (driveJobId === dialogJobId && driveState === 'CD_METADATA_SELECTION') { + stillInMetadataSelection = true; + break; + } + } + + if (!stillInMetadataSelection) { + setCdMetadataDialogVisible(false); + setCdMetadataDialogContext(null); + } + }, [cdMetadataDialogVisible, cdMetadataDialogContext?.jobId, pipeline?.cdDrives]); + useEffect(() => { setQueueState(normalizeQueue(pipeline?.queue)); }, [pipeline?.queue]); useEffect(() => { void loadDashboardJobs(); - }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, pipeline?.cdDrives, jobsRefreshToken]); + }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken]); useEffect(() => { api.getDetectedDrives() @@ -1553,18 +1635,28 @@ export default function DashboardPage({ try { const response = await api.deleteJobFiles(jobId, effectiveTarget); const summary = response?.summary || {}; - const deletedFiles = effectiveTarget === 'raw' - ? (summary.raw?.filesDeleted ?? 0) - : (summary.movie?.filesDeleted ?? 0); - const removedDirs = effectiveTarget === 'raw' - ? (summary.raw?.dirsRemoved ?? 0) - : (summary.movie?.dirsRemoved ?? 0); - toastRef.current?.show({ - severity: 'success', - summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht', - detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`, - life: 4000 - }); + const targetSummary = effectiveTarget === 'raw' + ? (summary.raw || {}) + : (summary.movie || {}); + const deletedFiles = targetSummary.filesDeleted ?? 0; + const removedDirs = targetSummary.dirsRemoved ?? 0; + const deletedSomething = Boolean(targetSummary.deleted); + + if (!deletedSomething) { + toastRef.current?.show({ + severity: 'warn', + summary: effectiveTarget === 'raw' ? 'RAW nicht gelöscht' : 'Movie nicht gelöscht', + detail: targetSummary.reason || 'Keine passenden Dateien/Ordner gefunden.', + life: 4200 + }); + } else { + toastRef.current?.show({ + severity: 'success', + summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht', + detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`, + life: 4000 + }); + } await loadDashboardJobs(); await refreshPipeline(); setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null }); @@ -2503,6 +2595,8 @@ export default function DashboardPage({ const canAnalyzeDrive = cdState ? cdState === 'DISC_DETECTED' : (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc); + const stateIconMeta = driveStateIconMeta(stateLabel); + const discArg = String(drv.discArg || '').trim(); return (
@@ -2510,10 +2604,27 @@ export default function DashboardPage({
{drivePath} {drv.model && {drv.model}} - {drv.discArg && {drv.discArg}}
- {stateLabel && } + {stateLabel ? ( + +