From d00191324bf09a00f84ea0b345927197ad0d3a08 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 17 Mar 2026 21:57:44 +0000 Subject: [PATCH] 0.11.0-5 multi lw --- .claude/settings.json | 8 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/pipelineRoutes.js | 15 ++ backend/src/services/diskDetectionService.js | 60 ++++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 7 + frontend/src/pages/DashboardPage.jsx | 270 ++++++++++++------- frontend/src/styles/app.css | 84 ++++++ package-lock.json | 4 +- package.json | 2 +- 12 files changed, 356 insertions(+), 106 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6fc5d73..4a56436 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,13 @@ "Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)", "Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)", "Bash(node --check /home/michael/ripster/backend/src/db/database.js)", - "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)" + "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)", + "Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", + "Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")", + "Bash(lsblk -o NAME,TYPE,MODEL)", + "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index b8468c3..842ca5b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.11.0-4", + "version": "0.11.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.11.0-4", + "version": "0.11.0-5", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index bcdfaa8..f53c280 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.11.0-4", + "version": "0.11.0-5", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 777789a..af1872c 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -54,6 +54,21 @@ router.post( }) ); +router.post( + '/rescan-drive', + asyncHandler(async (req, res) => { + const devicePath = String(req.body?.devicePath || '').trim(); + logger.info('post:rescan-drive', { reqId: req.reqId, devicePath }); + if (!devicePath) { + const err = new Error('devicePath ist erforderlich'); + err.statusCode = 400; + throw err; + } + const result = await diskDetectionService.rescanDriveAndEmit(devicePath); + res.json({ result }); + }) +); + router.get( '/omdb/search', asyncHandler(async (req, res) => { diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index 0ee1319..24df9cb 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -464,7 +464,65 @@ class DiskDetectionService extends EventEmitter { return results.filter(Boolean); } - return this.detectAllAuto(); + // Auto mode: scan all ROM drives via lsblk + const autoResults = await this.detectAllAuto(); + + // Fallback: if lsblk found no ROM drives but drive_device is configured, try it directly + if (autoResults.length === 0) { + const legacy = String(settingsMap.drive_device || '').trim(); + if (legacy) { + logger.debug('detect:auto:lsblk-empty-fallback-explicit', { legacy }); + const legacyResult = await this.detectExplicit(legacy); + if (legacyResult) { + return [legacyResult]; + } + } + } + + return autoResults; + } + + // Rescan a single specific drive and emit the appropriate event + async rescanDriveAndEmit(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return { present: false, emitted: 'none', device: null }; + } + try { + logger.info('rescan-drive:requested', { devicePath: normalized }); + const detected = await this.detectExplicit(normalized); + const previouslyTracked = this.detectedDiscs.has(normalized); + + if (detected) { + const previous = this.detectedDiscs.get(normalized); + const changed = previous ? buildSignature(previous) !== buildSignature(detected) : false; + this.detectedDiscs.set(normalized, detected); + this.lastDetected = detected; + this.lastPresent = true; + logger.info('rescan-drive:inserted', { devicePath: normalized, changed }); + this.emit('discInserted', detected); + return { present: true, emitted: 'discInserted', device: detected }; + } + + // No disc found + if (previouslyTracked) { + const old = this.detectedDiscs.get(normalized); + this.detectedDiscs.delete(normalized); + if (this.detectedDiscs.size === 0) { + this.lastDetected = null; + this.lastPresent = false; + } + logger.info('rescan-drive:removed', { devicePath: normalized }); + this.emit('discRemoved', old || { path: normalized }); + return { present: false, emitted: 'discRemoved', device: null }; + } + + logger.info('rescan-drive:empty', { devicePath: normalized }); + return { present: false, emitted: 'none', device: null }; + } catch (error) { + logger.error('rescan-drive:error', { devicePath: normalized, error: errorToMeta(error) }); + throw error; + } } // Multi-drive: tracks per-device state and emits insert/remove events for each diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1372b71..8cfaa48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.11.0-4", + "version": "0.11.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.11.0-4", + "version": "0.11.0-5", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 7d826a7..1023bca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.11.0-4", + "version": "0.11.0-5", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index dd5b5f2..f6f53c1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -489,6 +489,13 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, + async rescanDrive(devicePath) { + const result = await request('/pipeline/rescan-drive', { + method: 'POST', + body: JSON.stringify({ devicePath }) + }); + return result; + }, searchOmdb(q) { return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); }, diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 9685c6a..58212cd 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -870,6 +870,8 @@ export default function DashboardPage({ const [activationBytesBusy, setActivationBytesBusy] = useState(false); const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set()); const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false); + const [knownDrives, setKnownDrives] = useState([]); + const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set()); const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set()); const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); const [insertWaitSeconds, setInsertWaitSeconds] = useState(30); @@ -1062,6 +1064,12 @@ export default function DashboardPage({ void loadDashboardJobs(); }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]); + useEffect(() => { + api.getDetectedDrives() + .then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : [])) + .catch(() => {}); + }, []); + useEffect(() => { const requestedJobId = normalizeJobId(pendingExpandedJobId); if (!requestedJobId) { @@ -1327,20 +1335,29 @@ export default function DashboardPage({ } }; + const refreshKnownDrives = () => { + api.getDetectedDrives() + .then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : [])) + .catch(() => {}); + }; + const handleRescan = async () => { setBusy(true); try { const response = await api.rescanDisc(); + refreshKnownDrives(); + const allDetected = response?.result?.allDetected || []; const emitted = response?.result?.emitted || 'none'; + const count = allDetected.length; toastRef.current?.show({ - severity: emitted === 'discInserted' ? 'success' : 'info', - summary: 'Laufwerk neu gelesen', - detail: - emitted === 'discInserted' - ? 'Disk-Event wurde neu ausgelöst.' - : 'Kein Medium erkannt.', + severity: count > 0 ? 'success' : 'info', + summary: 'Laufwerke neu gelesen', + detail: count > 0 + ? `${count} Medium${count > 1 ? 'en' : ''} erkannt.` + : 'Kein Medium erkannt.', life: 2800 }); + void emitted; // used implicitly via count await refreshPipeline(); await loadDashboardJobs(); } catch (error) { @@ -1350,6 +1367,32 @@ export default function DashboardPage({ } }; + const handleRescanDrive = async (devicePath) => { + setDriveRescanBusy((prev) => new Set([...prev, devicePath])); + try { + const response = await api.rescanDrive(devicePath); + refreshKnownDrives(); + const emitted = response?.result?.emitted || 'none'; + toastRef.current?.show({ + severity: emitted === 'discInserted' ? 'success' : 'info', + summary: devicePath, + detail: emitted === 'discInserted' + ? 'Medium erkannt.' + : emitted === 'discRemoved' ? 'Medium entfernt.' : 'Leer.', + life: 2500 + }); + await refreshPipeline(); + } catch (error) { + showError(error); + } finally { + setDriveRescanBusy((prev) => { + const next = new Set(prev); + next.delete(devicePath); + return next; + }); + } + }; + const handleCancel = async (jobId = null, jobState = null) => { const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId; const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null; @@ -1987,6 +2030,23 @@ export default function DashboardPage({ const contextDevice = pipeline?.context?.device; const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null); const cdDriveEntries = Object.entries(pipeline?.cdDrives || {}); + + // Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state + const allDrives = useMemo(() => { + const driveMap = new Map(); + for (const d of knownDrives) { + driveMap.set(d.path, { path: d.path, model: d.model, discArg: d.discArg }); + } + for (const [drivePath, drive] of Object.entries(pipeline?.cdDrives || {})) { + const base = driveMap.get(drivePath) || { path: drivePath }; + driveMap.set(drivePath, { ...base, cdDrive: drive }); + } + if (device?.path) { + const base = driveMap.get(device.path) || { path: device.path, model: device.model }; + driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: state }); + } + return Array.from(driveMap.values()).sort((a, b) => (a.path || '').localeCompare(b.path || '')); + }, [knownDrives, pipeline?.cdDrives, device, state]); const isDriveActive = driveActiveStates.includes(state); const canRescan = !isDriveActive; const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state)); @@ -2229,9 +2289,10 @@ export default function DashboardPage({ + {/* Global action buttons (non-CD / DVD / Bluray) */}
- {/* Per-drive CD sections */} - {cdDriveEntries.length > 0 && ( -
- {cdDriveEntries.map(([drivePath, drive]) => { - const driveState = String(drive?.state || '').trim().toUpperCase(); - const driveDevice = drive?.device || {}; - const driveJobId = normalizeJobId(drive?.jobId); - const driveCtx = drive?.context && typeof drive.context === 'object' ? drive.context : {}; - const cdStateSeverity = driveState === 'FINISHED' ? 'success' - : driveState === 'ERROR' || driveState === 'CANCELLED' ? 'danger' - : ['CD_RIPPING', 'CD_ENCODING'].includes(driveState) ? 'warning' - : 'info'; - const driveProgress = Number(drive?.progress ?? 0); - const driveStatusText = drive?.statusText || null; - const isActiveRip = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(driveState); + {/* Compact per-drive list */} + {allDrives.length > 0 ? ( +
+ {allDrives.map((drv) => { + const drivePath = drv.path; + const cdDrive = drv.cdDrive; + const pipelineDevice = drv.pipelineDevice; + const isRescanBusy = driveRescanBusy.has(drivePath); + + // Determine display state + let stateLabel = null; + let stateSeverity = 'secondary'; + let discInfo = null; + let showProgress = false; + let progressValue = 0; + let statusText = null; + + if (cdDrive) { + const s = String(cdDrive.state || '').toUpperCase(); + stateLabel = s; + stateSeverity = s === 'FINISHED' ? 'success' + : s === 'ERROR' || s === 'CANCELLED' ? 'danger' + : ['CD_RIPPING', 'CD_ENCODING'].includes(s) ? 'warning' + : 'info'; + const cd = cdDrive.device || {}; + discInfo = cd.discLabel || cd.model || null; + showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s); + progressValue = Number(cdDrive.progress ?? 0); + statusText = cdDrive.statusText || null; + } else if (pipelineDevice) { + const s = String(drv.pipelineState || '').toUpperCase(); + stateLabel = s || 'DISC_DETECTED'; + stateSeverity = s === 'FINISHED' ? 'success' + : s === 'ERROR' ? 'danger' + : processingStates.includes(s) ? 'warning' + : 'info'; + discInfo = pipelineDevice.discLabel || pipelineDevice.label || null; + } else { + stateLabel = 'LEER'; + stateSeverity = 'secondary'; + } + + const driveJobId = normalizeJobId(cdDrive?.jobId); + const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {}; + const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null; + return ( -
-
- {drivePath} - -
- {(driveDevice.discLabel || driveDevice.model) && ( -
- {driveDevice.discLabel || driveDevice.model} +
+
+
+ {drivePath} + {drv.model && {drv.model}} + {drv.discArg && {drv.discArg}}
- )} - {isActiveRip && ( - - )} - {isActiveRip && driveStatusText && ( -
{driveStatusText}
- )} -
- {driveState === 'DISC_DETECTED' && ( +
+ {stateLabel && } +
+
+ {discInfo &&
{discInfo}
} + {showProgress && ( + + )} + {showProgress && statusText && ( +
{statusText}
+ )}
); })}
- )} - - {/* Non-CD / legacy single-drive display */} -
- {device - ? - : } -
- {device ? ( -
-
- Pfad: {device.path || '-'} -
-
- Modell: {device.model || '-'} -
-
- Disk-Label: {device.discLabel || '-'} -
-
- Laufwerks-Label: {device.label || '-'} -
-
- Mount: {device.mountpoint || '-'} -
-
) : ( - cdDriveEntries.length === 0 ?

Aktuell keine Disk erkannt.

: null +

Keine Laufwerke erkannt.

)} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b80edf4..4287f8b 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1802,6 +1802,90 @@ body { } /* Per-drive CD sections in Dashboard */ +/* Unified per-drive list (Disk-Information card) */ +.drive-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.drive-list-empty { + font-size: 0.85rem; + color: var(--rip-muted); + margin: 0.5rem 0 0; +} + +.drive-list-item { + padding: 0.4rem 0.6rem; + border: 1px solid var(--rip-border); + border-radius: 0.4rem; + background: var(--rip-panel-soft); +} + +.drive-list-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.drive-list-info { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} + +.drive-list-path { + font-size: 0.8rem; + color: var(--rip-muted); + white-space: nowrap; +} + +.drive-list-model { + font-size: 0.75rem; + color: var(--rip-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drive-list-disc-arg { + font-size: 0.7rem; + color: var(--rip-muted); + opacity: 0.7; +} + +.drive-list-state { + flex-shrink: 0; +} + +.drive-list-actions { + display: flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; +} + +.drive-list-disc-label { + font-size: 0.82rem; + margin-top: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drive-list-status-text { + font-size: 0.75rem; + color: var(--rip-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Legacy per-CD-drive section (kept for compat) */ .cd-drives-section { display: flex; flex-direction: column; diff --git a/package-lock.json b/package-lock.json index 691ad40..968b7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.11.0-4", + "version": "0.11.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.11.0-4", + "version": "0.11.0-5", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 16bf533..b0d409c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.11.0-4", + "version": "0.11.0-5", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",