diff --git a/.claude/settings.json b/.claude/settings.json index b0a3c42..d9a1b64 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,13 @@ "Read(//mnt/external/media/**)", "WebFetch(domain:www.makemkv.com)", "Bash(node --check backend/src/services/pipelineService.js)", - "Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)" + "Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)", + "Bash(find /home/michael/ripster -name *.db)", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")", + "Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)", + "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)" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index e85bda1..1953e65 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.2-25", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.2-25", + "version": "0.11.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 59946fd..051086d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.2-25", + "version": "0.11.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 7d76cc5..2b347af 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -962,6 +962,12 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerkspfade für den expliziten Modus als JSON-Array, z.B. [\"/dev/sr0\",\"/dev/sr1\"]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_devices', '[]')`); + await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('auto_eject_after_rip', 'Laufwerk', 'Laufwerk nach Rip auswerfen', 'boolean', 1, 'Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject).', 'false', '[]', '{}', 45)` diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 36500b9..b3eb49c 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -39,24 +39,6 @@ router.get( }) ); -router.get( - '/database', - asyncHandler(async (req, res) => { - logger.info('get:database', { - reqId: req.reqId, - status: req.query.status, - search: req.query.search - }); - - const rows = await historyService.getDatabaseRows({ - status: req.query.status, - search: req.query.search - }); - - res.json({ rows }); - }) -); - router.get( '/orphan-raw', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index e267d49..777789a 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -29,12 +29,22 @@ router.get( router.post( '/analyze', asyncHandler(async (req, res) => { - logger.info('post:analyze', { reqId: req.reqId }); - const result = await pipelineService.analyzeDisc(); + const devicePath = String(req.body?.devicePath || '').trim() || null; + logger.info('post:analyze', { reqId: req.reqId, devicePath }); + const result = await pipelineService.analyzeDisc(devicePath); res.json({ result }); }) ); +router.get( + '/cd/drives', + asyncHandler(async (req, res) => { + logger.debug('get:cd:drives', { reqId: req.reqId }); + const snapshot = pipelineService.getSnapshot(); + res.json({ cdDrives: snapshot.cdDrives || {} }); + }) +); + router.post( '/rescan-disc', asyncHandler(async (req, res) => { diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index aff7916..dc8c430 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -503,6 +503,8 @@ async function ripAndEncode(options) { } // ── Phase 2: Encode WAVs to target format ───────────────────────────────── + const encodeResults = []; + if (format === 'wav') { // Keep RAW WAVs in place and copy them to the final output structure. for (let i = 0; i < tracksToRip.length; i++) { @@ -532,8 +534,9 @@ async function ripAndEncode(options) { percent: 50 + ((i + 1) / tracksToRip.length) * 50 }); log('info', `WAV für Track ${track.position} gespeichert.`); + encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true }); } - return { outputDir, format, trackCount: tracksToRip.length }; + return { outputDir, format, trackCount: tracksToRip.length, encodeResults }; } for (let i = 0; i < tracksToRip.length; i++) { @@ -578,6 +581,7 @@ async function ripAndEncode(options) { if (String(error?.message || '').toLowerCase().includes('abgebrochen')) { throw error; } + encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false }); throw new Error( `${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})` ); @@ -605,9 +609,10 @@ async function ripAndEncode(options) { }); log('info', `Track ${track.position} encodiert.`); + encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true }); } - return { outputDir, format, trackCount: tracksToRip.length }; + return { outputDir, format, trackCount: tracksToRip.length, encodeResults }; } function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) { diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index 2485545..00fda4b 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -190,6 +190,7 @@ class DiskDetectionService extends EventEmitter { this.timer = null; this.lastDetected = null; this.lastPresent = false; + this.detectedDiscs = new Map(); // devicePath → device object (multi-drive tracking) this.deviceLocks = new Map(); this.pollingSuspended = false; } @@ -248,8 +249,8 @@ class DiskDetectionService extends EventEmitter { if (this.pollingSuspended) { logger.debug('poll:skip:suspended', { nextDelay }); } else if (autoDetectionEnabled) { - const detected = await this.detectDisc(map); - this.applyDetectionResult(detected, { forceInsertEvent: false }); + const detectedList = await this.detectAllDiscs(map); + this.applyMultiDetectionResults(detectedList, { forceInsertEvent: false }); } else { logger.debug('poll:skip:auto-detection-disabled', { nextDelay }); } @@ -270,17 +271,30 @@ class DiskDetectionService extends EventEmitter { driveDevice: map.drive_device }); - const detected = await this.detectDisc(map); - const result = this.applyDetectionResult(detected, { forceInsertEvent: true }); + const detectedList = await this.detectAllDiscs(map); + const results = this.applyMultiDetectionResults(detectedList, { forceInsertEvent: true }); + + const insertedResults = results.filter((r) => r.emitted === 'discInserted'); + const removedResults = results.filter((r) => r.emitted === 'discRemoved'); + const present = detectedList.length > 0; + const firstInserted = insertedResults[0] || null; logger.info('rescan:done', { - present: result.present, - emitted: result.emitted, - changed: result.changed, - detected: result.device || null + driveCount: detectedList.length, + inserted: insertedResults.length, + removed: removedResults.length }); - return result; + // Backward-compat return: report on first inserted device (or removed if nothing inserted) + return { + present, + changed: insertedResults.length > 0 || removedResults.length > 0, + emitted: insertedResults.length > 0 + ? 'discInserted' + : (removedResults.length > 0 ? 'discRemoved' : 'none'), + device: firstInserted?.device || null, + allDetected: detectedList + }; } catch (error) { logger.error('rescan:error', { error: errorToMeta(error) }); throw error; @@ -425,6 +439,83 @@ class DiskDetectionService extends EventEmitter { return this.detectAuto(); } + // Returns ALL discs with media (not just the first one) + async detectAllDiscs(settingsMap) { + if (settingsMap.drive_mode === 'explicit') { + // drive_devices is a JSON array of paths; fall back to legacy drive_device + let explicitPaths = []; + try { + const parsed = JSON.parse(settingsMap.drive_devices || '[]'); + if (Array.isArray(parsed)) { + explicitPaths = parsed.map((p) => String(p || '').trim()).filter(Boolean); + } + } catch (_error) { + // malformed JSON — ignore, fall through to legacy + } + if (explicitPaths.length === 0) { + const legacy = String(settingsMap.drive_device || '').trim(); + if (legacy) { + explicitPaths = [legacy]; + } + } + const results = await Promise.all(explicitPaths.map((p) => this.detectExplicit(p))); + return results.filter(Boolean); + } + + return this.detectAllAuto(); + } + + // Multi-drive: tracks per-device state and emits insert/remove events for each + applyMultiDetectionResults(detectedList, { forceInsertEvent = false } = {}) { + const detected = Array.isArray(detectedList) ? detectedList : []; + const newMap = new Map(); + for (const device of detected) { + if (device?.path) { + newMap.set(device.path, device); + } + } + + const results = []; + + // Check for new or changed devices + for (const [devicePath, device] of newMap) { + const previous = this.detectedDiscs.get(devicePath); + const changed = previous ? buildSignature(previous) !== buildSignature(device) : false; + const shouldEmitInserted = forceInsertEvent || !previous || changed; + if (shouldEmitInserted) { + logger.info('disc:inserted', { device, forceInsertEvent, changed }); + this.emit('discInserted', device); + results.push({ path: devicePath, emitted: 'discInserted', device }); + } else { + results.push({ path: devicePath, emitted: 'none', device }); + } + } + + // Check for removed devices + for (const [devicePath, device] of this.detectedDiscs) { + if (!newMap.has(devicePath)) { + logger.info('disc:removed', { device }); + this.emit('discRemoved', device); + results.push({ path: devicePath, emitted: 'discRemoved', device: null }); + } + } + + this.detectedDiscs = newMap; + + // Update legacy single-disc tracking for backward compat + if (newMap.size > 0) { + this.lastDetected = Array.from(newMap.values())[0]; + this.lastPresent = true; + } else { + if (this.lastPresent) { + this.lastDetected = null; + this.lastPresent = false; + } + } + + return results; + } + async detectExplicit(devicePath) { if (this.isDeviceLocked(devicePath)) { logger.debug('detect:explicit:locked', { @@ -527,6 +618,60 @@ class DiskDetectionService extends EventEmitter { return null; } + async detectAllAuto() { + const details = await this.getBlockDeviceInfo(); + const romCandidates = details.filter((entry) => entry.type === 'rom'); + const results = []; + + for (const item of romCandidates) { + const path = item.path || (item.name ? `/dev/${item.name}` : null); + if (!path) { + continue; + } + + if (this.isDeviceLocked(path)) { + logger.debug('detect:all-auto:skip-locked', { + path, + activeLocks: this.getActiveLocks() + }); + continue; + } + + const mediaState = await this.checkMediaPresent(path); + if (!mediaState.hasMedia) { + continue; + } + const discLabel = await this.getDiscLabel(path); + const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; + + const mediaProfile = await this.inferMediaProfile(path, { + discLabel, + label: item.label, + model: item.model, + fstype: detectedFsType, + mountpoint: item.mountpoint + }); + + const detected = { + mode: 'auto', + path, + name: item.name, + model: item.model || 'Optical Drive', + label: item.label || null, + discLabel: discLabel || null, + mountpoint: item.mountpoint || null, + fstype: detectedFsType, + mediaProfile: mediaProfile || null, + index: this.guessDiscIndex(item.name) + }; + logger.debug('detect:all-auto:found', { detected }); + results.push(detected); + } + + logger.debug('detect:all-auto:done', { count: results.length }); + return results; + } + async getBlockDeviceInfo() { try { const { stdout } = await execFileAsync('lsblk', [ diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 546477f..c9d156e 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -864,6 +864,10 @@ function buildJobDisplayTitle(job = null) { return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-'; } +function isHiddenDirectoryName(value) { + return String(value || '').trim().startsWith('.'); +} + function isFilesystemRootPath(inputPath) { const raw = String(inputPath || '').trim(); if (!raw) { @@ -1530,14 +1534,6 @@ class HistoryService { }; } - async getDatabaseRows(filters = {}) { - const jobs = await this.getJobs(filters); - return jobs.map((job) => ({ - ...job, - rawFolderName: job.raw_path ? path.basename(job.raw_path) : null - })); - } - async getOrphanRawFolders() { const settings = await settingsService.getSettingsMap(); const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); @@ -1586,7 +1582,7 @@ class HistoryService { const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); for (const entry of dirEntries) { - if (!entry.isDirectory()) { + if (!entry.isDirectory() || isHiddenDirectoryName(entry.name)) { continue; } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index b57fdc7..a6cc60c 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -3847,6 +3847,7 @@ class PipelineService extends EventEmitter { context: {} }; this.detectedDisc = null; + this.cdDrives = new Map(); // devicePath → per-drive CD state object this.activeProcess = null; this.activeProcesses = new Map(); this.cancelRequestedByJob = new Set(); @@ -4299,13 +4300,108 @@ class PipelineService extends EventEmitter { for (const [id, data] of this.jobProgress) { jobProgress[id] = data; } + const cdDrives = {}; + for (const [devicePath, driveState] of this.cdDrives) { + cdDrives[devicePath] = driveState; + } return { ...this.snapshot, jobProgress, - queue: this.lastQueueSnapshot + queue: this.lastQueueSnapshot, + cdDrives }; } + _setCdDriveState(devicePath, updates) { + const existing = this.cdDrives.get(devicePath) || { + devicePath, + state: 'DISC_DETECTED', + jobId: null, + device: null, + progress: 0, + eta: null, + statusText: null, + context: {} + }; + const next = { + ...existing, + ...updates, + devicePath + }; + this.cdDrives.set(devicePath, next); + + // Sync jobProgress so per-job queries work + const resolvedJobId = Number(next.jobId || 0); + if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) { + const prev = this.jobProgress.get(resolvedJobId) || {}; + const contextPatch = next.context && typeof next.context === 'object' ? next.context : null; + const mergedContext = contextPatch + ? { ...(prev.context || {}), ...contextPatch } + : (prev.context || null); + const nextJobProgress = { + ...prev, + state: next.state, + progress: next.progress ?? prev.progress ?? 0, + eta: next.eta ?? null, + statusText: next.statusText ?? null + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextJobProgress.context = mergedContext; + } + this.jobProgress.set(resolvedJobId, nextJobProgress); + } + + // Manage disc-polling suspension while any CD drive is actively ripping + const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); + const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); + if (anyActive) { + diskDetectionService.suspendPolling(); + } else { + diskDetectionService.resumePolling(); + } + + logger.info('cd:drive:state', { devicePath, state: next.state, jobId: next.jobId }); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + void this.emitQueueChanged(); + } + + _removeCdDrive(devicePath) { + const drive = this.cdDrives.get(devicePath); + if (!drive) { + return; + } + const jobId = Number(drive.jobId || 0); + this.cdDrives.delete(devicePath); + if (Number.isFinite(jobId) && jobId > 0) { + this.jobProgress.delete(jobId); + } + // Resume polling if no other active drives + const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); + const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); + if (!anyActive) { + diskDetectionService.resumePolling(); + } + logger.info('cd:drive:removed', { devicePath }); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + } + + _getCdDriveByJobId(jobId) { + const normalizedId = Number(jobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + for (const [devicePath, drive] of this.cdDrives) { + if (Number(drive.jobId) === normalizedId) { + return { ...drive, devicePath }; + } + } + return null; + } + normalizeParallelJobsLimit(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 1) { @@ -5207,21 +5303,39 @@ class PipelineService extends EventEmitter { if (enabled !== 'true' && enabled !== '1') { return; } - const device = devicePath - || (settingsMap?.drive_mode === 'explicit' ? String(settingsMap?.drive_device || '').trim() : null) - || String(settingsMap?.drive_device || '').trim() - || '/dev/sr0'; - logger.info('eject:drive', { device }); - await new Promise((resolve) => { - execFile('eject', [device], { timeout: 10000 }, (error) => { - if (error) { - logger.warn('eject:drive:failed', { device, error: errorToMeta(error) }); - } else { - logger.info('eject:drive:ok', { device }); + // Collect the list of drives to eject + const devicesToEject = []; + if (devicePath) { + devicesToEject.push(devicePath); + } else if (settingsMap?.drive_mode === 'explicit') { + try { + const parsed = JSON.parse(settingsMap?.drive_devices || '[]'); + if (Array.isArray(parsed)) { + parsed.map((p) => String(p || '').trim()).filter(Boolean).forEach((p) => devicesToEject.push(p)); } - resolve(); + } catch (_error) { + // ignore + } + if (devicesToEject.length === 0) { + const legacy = String(settingsMap?.drive_device || '').trim(); + if (legacy) devicesToEject.push(legacy); + } + } else { + devicesToEject.push(String(settingsMap?.drive_device || '').trim() || '/dev/sr0'); + } + for (const device of devicesToEject) { + logger.info('eject:drive', { device }); + await new Promise((resolve) => { + execFile('eject', [device], { timeout: 10000 }, (error) => { + if (error) { + logger.warn('eject:drive:failed', { device, error: errorToMeta(error) }); + } else { + logger.info('eject:drive:ok', { device }); + } + resolve(); + }); }); - }); + } } catch (error) { logger.warn('eject:drive:error', { error: errorToMeta(error) }); } @@ -5459,17 +5573,28 @@ class PipelineService extends EventEmitter { mediaProfile: resolvedMediaProfile }; + logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile }); + wsService.broadcast('DISC_DETECTED', { device: resolvedDevice }); + + // CD discs are tracked per-drive in cdDrives, not in the global state machine + if (resolvedMediaProfile === 'cd') { + const cdDevicePath = String(resolvedDevice.path || '').trim(); + if (cdDevicePath) { + const existingDrive = this.cdDrives.get(cdDevicePath); + const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']); + if (!ACTIVE_CD_STATES.has(existingDrive?.state)) { + this._setCdDriveState(cdDevicePath, { state: 'DISC_DETECTED', device: resolvedDevice }); + } + } + return; + } + const previousDevice = this.snapshot.context?.device || this.detectedDisc; const previousState = this.snapshot.state; const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null; const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false; this.detectedDisc = resolvedDevice; - logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile }); - - wsService.broadcast('DISC_DETECTED', { - device: resolvedDevice - }); if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') { const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`; @@ -5517,9 +5642,20 @@ class PipelineService extends EventEmitter { async onDiscRemoved(deviceInfo) { logger.info('disc:removed', { deviceInfo }); - wsService.broadcast('DISC_REMOVED', { - device: deviceInfo - }); + wsService.broadcast('DISC_REMOVED', { device: deviceInfo }); + + const removedPath = String(deviceInfo?.path || '').trim(); + + // If it's a tracked CD drive, remove or leave it depending on active state + if (removedPath && this.cdDrives.has(removedPath)) { + const driveState = this.cdDrives.get(removedPath); + const ACTIVE_CD_STATES = new Set(['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']); + if (!ACTIVE_CD_STATES.has(driveState?.state)) { + this._removeCdDrive(removedPath); + } + // If actively ripping, leave the entry – rip completion will clean it up + return; + } this.detectedDisc = null; if (this.snapshot.state === 'DISC_DETECTED') { @@ -5865,11 +6001,25 @@ class PipelineService extends EventEmitter { }; } - async analyzeDisc() { + async analyzeDisc(devicePath = null) { this.ensureNotBusy('analyze'); - logger.info('analyze:start'); + logger.info('analyze:start', { devicePath }); + + let device; + if (devicePath) { + const driveEntry = this.cdDrives.get(devicePath); + device = driveEntry?.device || null; + if (!device && this.detectedDisc?.path === devicePath) { + device = this.detectedDisc; + } + if (!device) { + // Minimal device object — cdRipService only needs path + device = { path: devicePath, mediaProfile: 'cd' }; + } + } else { + device = this.detectedDisc || this.snapshot.context?.device; + } - const device = this.detectedDisc || this.snapshot.context?.device; if (!device) { const error = new Error('Keine Disk erkannt.'); error.statusCode = 400; @@ -11381,32 +11531,60 @@ class PipelineService extends EventEmitter { ) : null; - await this.setState(finalState, { - activeJobId: jobId, - progress: this.snapshot.progress, - eta: null, - statusText: message, - context: { + const failContext = { + ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), + jobId, + stage, + error: message, + rawPath: job?.raw_path || null, + outputPath: job?.output_path || null, + mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile, + inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, + selectedMetadata: resolvedSelectedMetadata, + ...(isCdFailure ? { + tracks: resolvedTracks, + cdRipConfig: resolvedCdRipConfig, + cdLive: jobProgressContext?.cdLive || null, + devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null, + cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null + } : {}), + canRestartEncodeFromLastSettings: hasConfirmedPlan, + canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath + }; + + if (isCdFailure) { + const cdDevicePath = String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null; + if (cdDevicePath) { + this._setCdDriveState(cdDevicePath, { + state: finalState, + jobId, + progress: this.cdDrives.get(cdDevicePath)?.progress ?? 0, + eta: null, + statusText: message, + context: failContext + }); + } + } else { + await this.setState(finalState, { + activeJobId: jobId, + progress: this.snapshot.progress, + eta: null, + statusText: message, + context: { ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), jobId, stage, error: message, rawPath: job?.raw_path || null, outputPath: job?.output_path || null, - mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile, + mediaProfile: resolvedMediaProfile, inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, selectedMetadata: resolvedSelectedMetadata, - ...(isCdFailure ? { - tracks: resolvedTracks, - cdRipConfig: resolvedCdRipConfig, - cdLive: jobProgressContext?.cdLive || null, - devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null, - cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null - } : {}), canRestartEncodeFromLastSettings: hasConfirmedPlan, canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath - } - }); + }}); + } + this.cancelRequestedByJob.delete(Number(jobId)); void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', { @@ -12317,8 +12495,10 @@ class PipelineService extends EventEmitter { const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; // Read TOC - await this.setState('CD_ANALYZING', { - activeJobId: job.id, + this._setCdDriveState(devicePath, { + state: 'CD_ANALYZING', + jobId: job.id, + device, progress: 0, eta: null, statusText: 'CD wird analysiert …', @@ -12354,28 +12534,26 @@ class PipelineService extends EventEmitter { `CD analysiert: ${tracks.length} Track(s) gefunden.` ); - const runningJobs = await historyService.getRunningJobs(); - const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id)); - if (!foreignRunningJobs.length) { - const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null; - const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; - await this.setState('CD_METADATA_SELECTION', { - activeJobId: job.id, - progress: 0, - eta: null, - statusText: 'CD-Metadaten auswählen', - context: { - jobId: job.id, - device, - mediaProfile: 'cd', - devicePath, - cdparanoiaCmd, - cdparanoiaCommandPreview, - detectedTitle, - tracks - } - }); - } + const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null; + const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; + this._setCdDriveState(devicePath, { + state: 'CD_METADATA_SELECTION', + jobId: job.id, + device, + progress: 0, + eta: null, + statusText: 'CD-Metadaten auswählen', + context: { + jobId: job.id, + device, + mediaProfile: 'cd', + devicePath, + cdparanoiaCmd, + cdparanoiaCommandPreview, + detectedTitle, + tracks + } + }); return { jobId: job.id, detectedTitle, tracks }; } catch (error) { @@ -12480,18 +12658,20 @@ class PipelineService extends EventEmitter { `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` ); - if (this.isPrimaryJob(jobId)) { - const resolvedDevicePath = String(job?.disc_device || this.snapshot?.context?.device?.path || '').trim() || null; - const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; - const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null; - const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; - await this.setState('CD_READY_TO_RIP', { - activeJobId: jobId, + const resolvedDevicePath = String(job?.disc_device || '').trim() || null; + const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; + const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null; + const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; + const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null; + if (resolvedDevicePath) { + this._setCdDriveState(resolvedDevicePath, { + state: 'CD_READY_TO_RIP', + jobId, progress: 0, eta: null, statusText: 'CD bereit zum Rippen', context: { - ...(this.snapshot.context || {}), + ...(existingDrive?.context || {}), jobId, mediaProfile: 'cd', tracks: mergedTracks, @@ -12702,8 +12882,7 @@ class PipelineService extends EventEmitter { } const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; - const device = this.detectedDisc || this.snapshot.context?.device; - const devicePath = String(device?.path || activeJob.disc_device || '').trim(); + const devicePath = String(activeJob.disc_device || '').trim(); if (!devicePath) { const error = new Error('Kein CD-Laufwerk bekannt.'); @@ -12921,13 +13100,15 @@ class PipelineService extends EventEmitter { encode_plan_json: JSON.stringify(cdEncodePlan) }); - await this.setState('CD_RIPPING', { - activeJobId, + const existingCdDrive = this.cdDrives.get(devicePath); + this._setCdDriveState(devicePath, { + state: 'CD_RIPPING', + jobId: activeJobId, progress: 0, eta: null, statusText: 'CD wird gerippt …', context: { - ...(this.snapshot.context || {}), + ...(existingCdDrive?.context || {}), jobId: activeJobId, mediaProfile: 'cd', tracks: mergedTracks, @@ -13107,7 +13288,7 @@ class PipelineService extends EventEmitter { } } }; - await cdRipService.ripAndEncode({ + const { encodeResults: cdEncodeResults = [] } = await cdRipService.ripAndEncode({ jobId, devicePath, cdparanoiaCmd, @@ -13189,6 +13370,17 @@ class PipelineService extends EventEmitter { cdLive: buildLiveContext(null) } }); + // 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) } + }); + } }, onLog: async (level, msg) => { await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); @@ -13249,6 +13441,7 @@ class PipelineService extends EventEmitter { output_path: outputDir, handbrake_info_json: JSON.stringify({ mode: 'cd_rip', + tracks: cdEncodeResults, preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary }) @@ -13273,8 +13466,9 @@ class PipelineService extends EventEmitter { currentTrackPosition = null; const finishedCdLive = buildLiveContext(null); - await this.setState('FINISHED', { - activeJobId: jobId, + this._setCdDriveState(devicePath, { + state: 'FINISHED', + jobId, progress: 100, eta: null, statusText: finishedStatusText, @@ -13298,20 +13492,14 @@ class PipelineService extends EventEmitter { } catch (error) { settleLifecycle(); const failedCdLive = buildLiveContext(currentTrackPosition || null); - await this.updateProgress( - this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', - this.snapshot.progress, - null, - this.snapshot.statusText, - processKey, - { - contextPatch: { - cdLive: failedCdLive - } - } - ); + const currentDriveEntry = this.cdDrives.get(devicePath); + const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING'; + const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING'; + await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, { + contextPatch: { cdLive: failedCdLive } + }); logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); - await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error); + await this.failJob(jobId, failStage, error); } finally { this.activeProcesses.delete(processKey); this.syncPrimaryActiveProcess(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 6d4434a..6632e17 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1192,11 +1192,21 @@ class SettingsService { resolveHandBrakeSourceArg(map, deviceInfo = null) { if (map.drive_mode === 'explicit') { - const device = String(map.drive_device || '').trim(); - if (!device) { - throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.'); + // Prefer drive_devices array, fall back to legacy drive_device + let explicitPaths = []; + try { + const parsed = JSON.parse(map.drive_devices || '[]'); + if (Array.isArray(parsed)) { + explicitPaths = parsed.map((p) => String(p || '').trim()).filter(Boolean); + } + } catch (_error) { + // ignore parse error } - return device; + const firstExplicit = explicitPaths[0] || String(map.drive_device || '').trim(); + if (!firstExplicit) { + throw new Error('Kein Laufwerk konfiguriert, obwohl drive_mode=explicit gesetzt ist.'); + } + return firstExplicit; } const detectedPath = String(deviceInfo?.path || '').trim(); @@ -1204,6 +1214,16 @@ class SettingsService { return detectedPath; } + // Fallback: first explicit device, or legacy drive_device + try { + const parsed = JSON.parse(map.drive_devices || '[]'); + if (Array.isArray(parsed) && parsed.length > 0) { + const first = String(parsed[0] || '').trim(); + if (first) return first; + } + } catch (_error) { + // ignore + } const configuredPath = String(map.drive_device || '').trim(); if (configuredPath) { return configuredPath; diff --git a/docs/api/history.md b/docs/api/history.md index fc861b6..b9f4f34 100644 --- a/docs/api/history.md +++ b/docs/api/history.md @@ -82,26 +82,6 @@ Liefert Job-Detail. --- -## GET /api/history/database - -Debug-Ansicht der DB-Zeilen (angereichert). - -**Response:** - -```json -{ - "rows": [ - { - "id": 42, - "status": "FINISHED", - "rawFolderName": "Inception - RAW - job-42" - } - ] -} -``` - ---- - ## GET /api/history/orphan-raw Sucht RAW-Ordner ohne zugehörigen Job. diff --git a/docs/gui/database.md b/docs/gui/database.md index aa11d22..5eb451f 100644 --- a/docs/gui/database.md +++ b/docs/gui/database.md @@ -9,17 +9,7 @@ ## Bereiche -### 1) `Historie & Datenbank` - -Tabellarische Jobansicht mit: - -- ID, Poster, Medium, Titel -- Status -- Start/Ende - -Aktionen im Detaildialog entsprechen weitgehend der Seite `Historie` (inkl. Re-Encode, Review-Neustart, OMDb-Zuordnung, Dateilöschung). - -### 2) `RAW ohne Historie` +### `Gefundene RAW-Einträge` Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben. diff --git a/docs/workflows/index.md b/docs/workflows/index.md index 169beff..17755d2 100644 --- a/docs/workflows/index.md +++ b/docs/workflows/index.md @@ -51,11 +51,11 @@ In `Historie` -> Detaildialog: ### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session -- in `Historie` oder `Database`: `Im Dashboard öffnen` +- in `Historie`: `Im Dashboard öffnen` - im Dashboard Review erneut prüfen und starten ### Fall C: RAW ohne Historieneintrag - `/database` öffnen -- Bereich `RAW ohne Historie` +- Bereich `Gefundene RAW-Einträge` - `Job anlegen` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68d3aeb..d0490d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.2-25", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.2-25", + "version": "0.11.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 840d0ef..6c43ea4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.2-25", + "version": "0.11.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4ca2564..d419203 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -110,6 +110,7 @@ function App() { const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} }); const [hardwareMonitoring, setHardwareMonitoring] = useState(null); const [lastDiscEvent, setLastDiscEvent] = useState(null); + const [expertMode, setExpertMode] = useState(false); const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState()); const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0); const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); @@ -232,6 +233,13 @@ function App() { setDownloadSummary(response?.summary || null); }) .catch(() => null); + api.getSettings() + .then((response) => { + const allSettings = (response?.categories || []).flatMap((c) => c.settings || []); + const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value; + setExpertMode(val === 'true' || val === true); + }) + .catch(() => null); }, []); useWebSocket({ @@ -307,6 +315,27 @@ function App() { setHardwareMonitoring(message.payload || null); } + if (message.type === 'SETTINGS_UPDATED') { + const setting = message.payload; + if (setting?.key === 'ui_expert_mode') { + const val = setting?.value; + setExpertMode(val === 'true' || val === true); + } + } + + if (message.type === 'SETTINGS_BULK_UPDATED') { + const keys = message.payload?.keys || []; + if (keys.includes('ui_expert_mode')) { + api.getSettings({ forceRefresh: true }) + .then((response) => { + const allSettings = (response?.categories || []).flatMap((c) => c.settings || []); + const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value; + setExpertMode(val === 'true' || val === true); + }) + .catch(() => null); + } + } + if (message.type === 'DOWNLOADS_UPDATED') { const summary = message.payload?.summary && typeof message.payload.summary === 'object' ? message.payload.summary @@ -346,7 +375,8 @@ function App() { { label: 'Dashboard', path: '/' }, { label: 'Settings', path: '/settings' }, { label: 'Historie', path: '/history' }, - { label: 'Downloads', path: '/downloads' } + { label: 'Downloads', path: '/downloads' }, + ...(expertMode ? [{ label: 'Database', path: '/database' }] : []) ]; const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); const showAudiobookUploadBanner = uploadPhase !== 'idle'; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 83e6983..dcc29b9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -470,9 +470,11 @@ export const api = { body: JSON.stringify({}) }); }, - async analyzeDisc() { + async analyzeDisc(devicePath = null) { + const body = devicePath ? { devicePath } : {}; const result = await request('/pipeline/analyze', { - method: 'POST' + method: 'POST', + body: JSON.stringify(body) }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; @@ -645,13 +647,6 @@ export const api = { const suffix = query.toString() ? `?${query.toString()}` : ''; return request(`/history${suffix}`); }, - getDatabaseRows(params = {}) { - const query = new URLSearchParams(); - if (params.status) query.set('status', params.status); - if (params.search) query.set('search', params.search); - const suffix = query.toString() ? `?${query.toString()}` : ''; - return request(`/history/database${suffix}`); - }, getOrphanRawFolders() { return request('/history/orphan-raw'); }, diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index c220c5c..0784c76 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -6,6 +6,7 @@ import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; import { Dropdown } from 'primereact/dropdown'; import { Tag } from 'primereact/tag'; +import { Button } from 'primereact/button'; function normalizeText(value) { return String(value || '').trim().toLowerCase(); @@ -87,6 +88,64 @@ function toBoolean(value) { return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; } +function DriveDevicesEditor({ value, onChange, settingKey }) { + const parseDevices = (val) => { + try { + const parsed = JSON.parse(val || '[]'); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch (_e) { + return []; + } + }; + + const devices = parseDevices(value); + + const updateDevices = (newDevices) => { + onChange?.(settingKey, JSON.stringify(newDevices)); + }; + + return ( +
+ {devices.length === 0 && ( +

Keine expliziten Laufwerke konfiguriert.

+ )} + {devices.map((path, idx) => ( +
+ { + const next = [...devices]; + next[idx] = e.target.value; + updateDevices(next); + }} + className="drive-device-input" + /> +
+ ))} +
+ ); +} + function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) { const key = normalizeSettingKey(settingKey); if (!key) { @@ -245,7 +304,7 @@ function SettingField({ )} - {setting.type === 'string' || setting.type === 'path' ? ( + {(setting.type === 'string' || setting.type === 'path') && setting.key !== 'drive_devices' ? ( ) : null} + {setting.key === 'drive_devices' ? ( + + ) : null} + {setting.type === 'number' ? ( { - api.getSettings().then((res) => { - const allSettings = (res?.categories || []).flatMap((c) => c.settings || []); - const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value; - setExpertMode(val === 'true' || val === true); - }).catch(() => { }); - }, []); - const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS'; const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status); const showFinalLog = !running; @@ -825,13 +814,6 @@ export default function JobDetailDialog({ {job.error_message ? (
Fehler: {job.error_message}
) : null} - {expertMode ? ( -
- - DB-Eintrag ansehen - -
- ) : null} @@ -948,6 +930,10 @@ export default function JobDetailDialog({ const selectedSet = Array.isArray(job.encodePlan?.selectedTracks) && job.encodePlan.selectedTracks.length > 0 ? new Set(job.encodePlan.selectedTracks.map(Number)) : null; + const encodeResultMap = new Map( + (Array.isArray(job.handbrakeInfo?.tracks) ? job.handbrakeInfo.tracks : []) + .map((r) => [Number(r.position), r]) + ); if (tracksRaw.length === 0) return

Keine Tracks vorhanden.

; return (
@@ -962,11 +948,13 @@ export default function JobDetailDialog({ ? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min` : '-'; const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false; - const ripDone = job?.ripSuccessful != null; + const encodeResult = encodeResultMap.get(pos); const encodeLabel = !selected ? 'Nicht übernommen' - : ripDone ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') - : 'Übernommen'; - const encodeClass = !selected ? '' : ripDone ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : ''; + : encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler') + : (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt'); + const encodeClass = !selected ? '' + : encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no') + : (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : ''); return (
#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 12660de..7d1bbac 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1036,23 +1036,23 @@ export default function DashboardPage({ } }, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]); - // Auto-open CD metadata dialog when pipeline enters CD_METADATA_SELECTION + // Auto-open CD metadata dialog when any drive enters CD_METADATA_SELECTION useEffect(() => { - const currentState = String(pipeline?.state || '').trim().toUpperCase(); - if (currentState === 'CD_METADATA_SELECTION') { - const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null; - if (ctx?.jobId && !cdMetadataDialogVisible) { + const cdDrives = pipeline?.cdDrives; + if (!cdDrives || typeof cdDrives !== 'object') return; + 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; + if (driveState === 'CD_METADATA_SELECTION' && ctx?.jobId && !cdMetadataDialogVisible) { setCdMetadataDialogContext(ctx); setCdMetadataDialogVisible(true); + break; } - } - if (currentState === 'CD_READY_TO_RIP') { - const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null; - if (ctx?.jobId) { + if (driveState === 'CD_READY_TO_RIP' && ctx?.jobId) { setCdRipPanelJobId(ctx.jobId); } } - }, [pipeline?.state, pipeline?.context?.jobId]); + }, [pipeline?.cdDrives]); useEffect(() => { setQueueState(normalizeQueue(pipeline?.queue)); @@ -1314,6 +1314,19 @@ export default function DashboardPage({ await handleAnalyze(); }; + const handleAnalyzeForDrive = async (devicePath) => { + setBusy(true); + try { + await api.analyzeDisc(devicePath); + await refreshPipeline(); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + const handleRescan = async () => { setBusy(true); try { @@ -1969,7 +1982,11 @@ export default function DashboardPage({ } }; - const device = lastDiscEvent || pipeline?.context?.device; + // CD drives are tracked per-drive in pipeline.cdDrives — exclude them from the single-drive display + const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null; + const contextDevice = pipeline?.context?.device; + const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null); + const cdDriveEntries = Object.entries(pipeline?.cdDrives || {}); const isDriveActive = driveActiveStates.includes(state); const canRescan = !isDriveActive; const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state)); @@ -2236,6 +2253,74 @@ export default function DashboardPage({ disabled={!canOpenMetadataModal} />
+ + {/* 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'; + return ( +
+
+ {driveDevice.model || 'CD-Laufwerk'} + {drivePath} + +
+ {driveDevice.discLabel && ( +
+ Disc: {driveDevice.discLabel} +
+ )} +
+ {driveState === 'DISC_DETECTED' && ( +
+
+ ); + })} +
+ )} + + {/* Non-CD / legacy single-drive display */}
{device ? @@ -2260,7 +2345,7 @@ export default function DashboardPage({
) : ( -

Aktuell keine Disk erkannt.

+ cdDriveEntries.length === 0 ?

Aktuell keine Disk erkannt.

: null )} diff --git a/frontend/src/pages/DatabasePage.jsx b/frontend/src/pages/DatabasePage.jsx index c9f656d..8c3219f 100644 --- a/frontend/src/pages/DatabasePage.jsx +++ b/frontend/src/pages/DatabasePage.jsx @@ -1,115 +1,34 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Card } from 'primereact/card'; import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; -import { InputText } from 'primereact/inputtext'; -import { Dropdown } from 'primereact/dropdown'; import { Tag } from 'primereact/tag'; import { Button } from 'primereact/button'; import { Toast } from 'primereact/toast'; import { api } from '../api/client'; -import JobDetailDialog from '../components/JobDetailDialog'; -import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; -import CdMetadataDialog from '../components/CdMetadataDialog'; -import blurayIndicatorIcon from '../assets/media-bluray.svg'; -import discIndicatorIcon from '../assets/media-disc.svg'; -import otherIndicatorIcon from '../assets/media-other.svg'; -import { - getStatusLabel, - getStatusSeverity, - normalizeStatus, - STATUS_FILTER_OPTIONS -} from '../utils/statusPresentation'; -function resolveMediaType(row) { - const candidates = [ - row?.mediaType, - row?.media_type, - row?.mediaProfile, - row?.media_profile, - row?.encodePlan?.mediaProfile, - row?.makemkvInfo?.analyzeContext?.mediaProfile, - row?.makemkvInfo?.mediaProfile, - row?.mediainfoInfo?.mediaProfile - ]; - for (const candidate of candidates) { - const raw = String(candidate || '').trim().toLowerCase(); - if (!raw) { - continue; - } - if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) { - return 'bluray'; - } - if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { - return 'dvd'; - } - if (['cd', 'audio_cd'].includes(raw)) { - return 'cd'; - } - if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { - return 'audiobook'; - } +function formatDetectedMediaType(value) { + const mediaType = String(value || '').trim().toLowerCase(); + if (mediaType === 'bluray') { + return 'Blu-ray'; } - return 'other'; -} - -function normalizeJobId(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return null; + if (mediaType === 'dvd') { + return 'DVD'; } - return Math.trunc(parsed); -} - -function getQueueActionResult(response) { - return response?.result && typeof response.result === 'object' ? response.result : {}; + if (mediaType === 'cd') { + return 'CD'; + } + if (mediaType === 'audiobook') { + return 'Audiobook'; + } + return 'Sonstiges'; } export default function DatabasePage() { - const [rows, setRows] = useState([]); const [orphanRows, setOrphanRows] = useState([]); - const [search, setSearch] = useState(''); - const [status, setStatus] = useState(''); - const [loading, setLoading] = useState(false); const [orphanLoading, setOrphanLoading] = useState(false); - const [selectedJob, setSelectedJob] = useState(null); - const [detailVisible, setDetailVisible] = useState(false); - const [detailLoading, setDetailLoading] = useState(false); - const [logLoadingMode, setLogLoadingMode] = useState(null); - const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); - const [metadataDialogContext, setMetadataDialogContext] = useState(null); - const [metadataDialogBusy, setMetadataDialogBusy] = useState(false); - const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); - const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null); - const [cdMetadataDialogBusy, setCdMetadataDialogBusy] = useState(false); - const [actionBusy, setActionBusy] = useState(false); - const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null); - const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null); const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null); - const [queuedJobIds, setQueuedJobIds] = useState([]); const toastRef = useRef(null); - const queuedJobIdSet = useMemo(() => { - const next = new Set(); - for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) { - const id = normalizeJobId(value); - if (id) { - next.add(id); - } - } - return next; - }, [queuedJobIds]); - - const loadRows = async () => { - setLoading(true); - try { - const response = await api.getDatabaseRows({ search, status }); - setRows(response.rows || []); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message }); - } finally { - setLoading(false); - } - }; const loadOrphans = async () => { setOrphanLoading(true); @@ -123,357 +42,9 @@ export default function DatabasePage() { } }; - const loadQueue = async () => { - try { - const response = await api.getPipelineQueue(); - const queuedRows = Array.isArray(response?.queue?.queuedJobs) ? response.queue.queuedJobs : []; - const queuedIds = queuedRows - .map((item) => normalizeJobId(item?.jobId)) - .filter(Boolean); - setQueuedJobIds(queuedIds); - } catch (_error) { - setQueuedJobIds([]); - } - }; - - const load = async () => { - await Promise.all([loadRows(), loadOrphans(), loadQueue()]); - }; - useEffect(() => { - const timer = setTimeout(() => { - load(); - }, 250); - - return () => clearTimeout(timer); - }, [search, status]); - - useEffect(() => { - if (!detailVisible || !selectedJob?.id) { - return undefined; - } - - const shouldPoll = - ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(selectedJob.status) || - (selectedJob.status === 'READY_TO_ENCODE' && !selectedJob.encodePlan); - - if (!shouldPoll) { - return undefined; - } - - let cancelled = false; - const refreshDetail = async () => { - try { - const response = await api.getJob(selectedJob.id, { includeLogs: false }); - if (!cancelled) { - setSelectedJob(response.job); - } - } catch (_error) { - // ignore polling errors; user can manually refresh - } - }; - - const interval = setInterval(refreshDetail, 2500); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [detailVisible, selectedJob?.id, selectedJob?.status, selectedJob?.encodePlan]); - - const openDetail = async (row) => { - const jobId = Number(row?.id || 0); - if (!jobId) { - return; - } - - setSelectedJob({ - ...row, - logs: [], - log: '', - logMeta: { - loaded: false, - total: Number(row?.log_count || 0), - returned: 0, - truncated: false - } - }); - setDetailVisible(true); - setDetailLoading(true); - - try { - const response = await api.getJob(jobId, { includeLogs: false }); - setSelectedJob(response.job); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message }); - } finally { - setDetailLoading(false); - } - }; - - const refreshDetailIfOpen = async (jobId) => { - if (!detailVisible || !selectedJob || selectedJob.id !== jobId) { - return; - } - - const response = await api.getJob(jobId, { includeLogs: false }); - setSelectedJob(response.job); - }; - - const handleLoadLog = async (job, mode = 'tail') => { - const jobId = Number(job?.id || selectedJob?.id || 0); - if (!jobId) { - return; - } - - setLogLoadingMode(mode); - try { - const response = await api.getJob(jobId, { - includeLogs: true, - includeAllLogs: mode === 'all', - logTailLines: mode === 'all' ? null : 800 - }); - setSelectedJob(response.job); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message }); - } finally { - setLogLoadingMode(null); - } - }; - - const handleDeleteFiles = async (row, target) => { - const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie'; - const title = row.title || row.detected_title || `Job #${row.id}`; - const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`); - if (!confirmed) { - return; - } - - setActionBusy(true); - try { - const response = await api.deleteJobFiles(row.id, target); - const summary = response.summary || {}; - toastRef.current?.show({ - severity: 'success', - summary: 'Dateien gelöscht', - detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`, - life: 3500 - }); - await load(); - await refreshDetailIfOpen(row.id); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 }); - } finally { - setActionBusy(false); - } - }; - - const handleReencode = async (row) => { - const title = row.title || row.detected_title || `Job #${row.id}`; - const confirmed = window.confirm(`Re-Encode aus RAW für "${title}" starten? Der bestehende Job wird aktualisiert.`); - if (!confirmed) { - return; - } - - setReencodeBusyJobId(row.id); - try { - const response = await api.reencodeJob(row.id); - toastRef.current?.show({ - severity: 'success', - summary: 'Re-Encode gestartet', - detail: 'Bestehender Job wurde in die Mediainfo-Prüfung gesetzt.', - life: 3500 - }); - await load(); - await refreshDetailIfOpen(row.id); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 }); - } finally { - setReencodeBusyJobId(null); - } - }; - - const handleRestartEncode = async (row) => { - const title = row.title || row.detected_title || `Job #${row.id}`; - if (row?.encodeSuccess) { - const confirmed = window.confirm( - `Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` + - 'Es wird eine neue Datei mit Kollisionsprüfung angelegt.' - ); - if (!confirmed) { - return; - } - } - - setActionBusy(true); - try { - const response = await api.restartEncodeWithLastSettings(row.id); - const result = getQueueActionResult(response); - if (result.queued) { - const queuePosition = Number(result?.queuePosition || 0); - toastRef.current?.show({ - severity: 'info', - summary: 'Encode-Neustart in Queue', - detail: queuePosition > 0 - ? `Job wurde auf Position ${queuePosition} eingeplant.` - : 'Job wurde in die Warteschlange eingeplant.', - life: 3500 - }); - } else { - toastRef.current?.show({ - severity: 'success', - summary: 'Encode-Neustart gestartet', - detail: 'Letzte bestätigte Einstellungen werden verwendet.', - life: 3500 - }); - } - await load(); - await refreshDetailIfOpen(row.id); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 }); - } finally { - setActionBusy(false); - } - }; - - const handleRestartReview = async (row) => { - setActionBusy(true); - try { - await api.restartReviewFromRaw(row.id); - toastRef.current?.show({ - severity: 'success', - summary: 'Review-Neustart gestartet', - detail: 'Die Titel-/Spurprüfung wird aus dem RAW neu berechnet.', - life: 3500 - }); - await load(); - await refreshDetailIfOpen(row.id); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 }); - } finally { - setActionBusy(false); - } - }; - - const handleRemoveFromQueue = async (row) => { - const jobId = normalizeJobId(row?.id || row); - if (!jobId) { - return; - } - - setActionBusy(true); - try { - await api.cancelPipeline(jobId); - toastRef.current?.show({ - severity: 'success', - summary: 'Aus Queue entfernt', - detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`, - life: 3200 - }); - await load(); - await refreshDetailIfOpen(jobId); - } catch (error) { - toastRef.current?.show({ - severity: 'error', - summary: 'Queue-Entfernung fehlgeschlagen', - detail: error.message, - life: 4500 - }); - } finally { - setActionBusy(false); - } - }; - - const handleResumeReady = async (row) => { - setActionBusy(true); - try { - await api.resumeReadyJob(row.id); - toastRef.current?.show({ - severity: 'success', - summary: 'Job ins Dashboard geladen', - detail: 'Job ist wieder im Dashboard aktiv.', - life: 3200 - }); - await load(); - await refreshDetailIfOpen(row.id); - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'Laden fehlgeschlagen', detail: error.message, life: 4500 }); - } finally { - setActionBusy(false); - } - }; - - const mapDeleteChoice = (value) => { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'raw') return 'raw'; - if (normalized === 'fertig') return 'movie'; - if (normalized === 'beides') return 'both'; - if (normalized === 'nichts') return 'none'; - if (normalized === 'movie') return 'movie'; - if (normalized === 'both') return 'both'; - if (normalized === 'none') return 'none'; - return null; - }; - - const handleDeleteEntry = async (row) => { - const title = row.title || row.detected_title || `Job #${row.id}`; - const choiceRaw = window.prompt( - `Was soll beim Löschen von "${title}" mit gelöscht werden?\n` + - '- raw\n' + - '- fertig\n' + - '- beides\n' + - '- nichts', - 'nichts' - ); - - if (choiceRaw === null) { - return; - } - - const target = mapDeleteChoice(choiceRaw); - if (!target) { - toastRef.current?.show({ - severity: 'warn', - summary: 'Ungültige Eingabe', - detail: 'Bitte genau eine Option verwenden: raw, fertig, beides, nichts.', - life: 4200 - }); - return; - } - - const confirmed = window.confirm( - `Historieneintrag "${title}" wirklich löschen? Auswahl: ${target === 'movie' ? 'fertig' : target}` - ); - if (!confirmed) { - return; - } - - setDeleteEntryBusyJobId(row.id); - try { - const response = await api.deleteJobEntry(row.id, target); - const rawDeleted = response?.fileSummary?.raw?.filesDeleted ?? 0; - const movieDeleted = response?.fileSummary?.movie?.filesDeleted ?? 0; - toastRef.current?.show({ - severity: 'success', - summary: 'Historieneintrag gelöscht', - detail: `Dateien entfernt: RAW ${rawDeleted}, Fertig ${movieDeleted}`, - life: 4200 - }); - if (selectedJob?.id === row.id) { - setDetailVisible(false); - setSelectedJob(null); - } - await load(); - } catch (error) { - toastRef.current?.show({ - severity: 'error', - summary: 'Löschen fehlgeschlagen', - detail: error.message, - life: 5000 - }); - } finally { - setDeleteEntryBusyJobId(null); - } - }; + void loadOrphans(); + }, []); const handleImportOrphanRaw = async (row) => { const target = row?.rawPath || row?.folderName || '-'; @@ -511,7 +82,7 @@ export default function DatabasePage() { life: 3500 }); } - await load(); + await loadOrphans(); } catch (error) { toastRef.current?.show({ severity: 'error', @@ -523,204 +94,15 @@ export default function DatabasePage() { setOrphanImportBusyPath(null); } }; - - const handleOmdbSearch = async (query) => { - try { - const response = await api.searchOmdb(query); - return response.results || []; - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'OMDb Suche fehlgeschlagen', detail: error.message, life: 4500 }); - return []; - } - }; - - const handleMusicBrainzSearch = async (query) => { - try { - const response = await api.searchMusicBrainz(query); - return response.results || []; - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Suche fehlgeschlagen', detail: error.message, life: 4500 }); - return []; - } - }; - - const handleMusicBrainzReleaseFetch = async (mbId) => { - try { - const response = await api.getMusicBrainzRelease(mbId); - return response.release || null; - } catch (error) { - toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Release fehlgeschlagen', detail: error.message, life: 4500 }); - return null; - } - }; - - const openCdMetadataAssignDialog = (row) => { - if (!row?.id) { - return; - } - const makemkvInfo = row.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {}; - const tocTracks = Array.isArray(makemkvInfo.tracks) ? makemkvInfo.tracks : []; - const selectedMetadata = makemkvInfo.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' - ? makemkvInfo.selectedMetadata - : {}; - setCdMetadataDialogContext({ - jobId: row.id, - detectedTitle: row.title || row.detected_title || selectedMetadata.title || '', - tracks: tocTracks - }); - setCdMetadataDialogVisible(true); - }; - - const handleCdMetadataAssignSubmit = async (payload) => { - const jobId = Number(payload?.jobId || cdMetadataDialogContext?.jobId || 0); - if (!jobId) { - return; - } - - setCdMetadataDialogBusy(true); - try { - const response = await api.assignJobCdMetadata(jobId, payload); - toastRef.current?.show({ - severity: 'success', - summary: 'CD-Metadaten aktualisiert', - detail: `Job #${jobId} wurde aktualisiert.`, - life: 3500 - }); - setCdMetadataDialogVisible(false); - await load(); - if (detailVisible && selectedJob?.id === jobId && response?.job) { - setSelectedJob(response.job); - } else { - await refreshDetailIfOpen(jobId); - } - } catch (error) { - toastRef.current?.show({ - severity: 'error', - summary: 'CD-Metadaten fehlgeschlagen', - detail: error.message, - life: 5000 - }); - } finally { - setCdMetadataDialogBusy(false); - } - }; - - const openMetadataAssignDialog = (row) => { - if (!row?.id) { - return; - } - const detectedTitle = row.title || row.detected_title || ''; - const imdbId = String(row.imdb_id || '').trim(); - const seedRows = imdbId - ? [{ - title: row.title || row.detected_title || detectedTitle || imdbId, - year: row.year || '', - imdbId, - type: 'movie', - poster: row.poster_url || null - }] - : []; - - setMetadataDialogContext({ - jobId: row.id, - detectedTitle, - selectedMetadata: { - title: row.title || row.detected_title || '', - year: row.year || '', - imdbId, - poster: row.poster_url || null - }, - omdbCandidates: seedRows - }); - setMetadataDialogVisible(true); - }; - - const handleMetadataAssignSubmit = async (payload) => { - const jobId = Number(payload?.jobId || metadataDialogContext?.jobId || 0); - if (!jobId) { - return; - } - - setMetadataDialogBusy(true); - try { - const response = await api.assignJobOmdb(jobId, payload); - toastRef.current?.show({ - severity: 'success', - summary: 'OMDb-Zuordnung aktualisiert', - detail: `Job #${jobId} wurde aktualisiert.`, - life: 3500 - }); - setMetadataDialogVisible(false); - await load(); - if (detailVisible && selectedJob?.id === jobId && response?.job) { - setSelectedJob(response.job); - } else { - await refreshDetailIfOpen(jobId); - } - } catch (error) { - toastRef.current?.show({ - severity: 'error', - summary: 'OMDb-Zuordnung fehlgeschlagen', - detail: error.message, - life: 5000 - }); - } finally { - setMetadataDialogBusy(false); - } - }; - - const posterBody = (row) => - row.poster_url && row.poster_url !== 'N/A' ? ( - {row.title - ) : ( - - - ); - - const titleBody = (row) => ( -
-
{row.title || row.detected_title || '-'}
- {row.year || '-'} | {row.imdb_id || '-'} -
- ); - - const stateBody = (row) => { - const normalizedStatus = normalizeStatus(row?.status); - const rowId = normalizeJobId(row?.id); - const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId)); - return ( - - ); - }; - const mediaBody = (row) => { - const mediaType = resolveMediaType(row); - const src = mediaType === 'bluray' - ? blurayIndicatorIcon - : (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon); - const alt = mediaType === 'bluray' - ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium')); - const title = mediaType === 'bluray' - ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium')); - const label = mediaType === 'bluray' - ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges')); - return ( - - {alt} - {label} - - ); - }; const orphanTitleBody = (row) => (
{row.title || '-'}
{row.year || '-'} | {row.imdbId || '-'}
); + const orphanMediaBody = (row) => ( + + ); const orphanPathBody = (row) => (
{row.rawPath} @@ -733,7 +115,7 @@ export default function DatabasePage() { size="small" onClick={() => handleImportOrphanRaw(row)} loading={orphanImportBusyPath === row.rawPath} - disabled={Boolean(orphanImportBusyPath) || Boolean(actionBusy)} + disabled={Boolean(orphanImportBusyPath)} /> ); @@ -741,50 +123,9 @@ export default function DatabasePage() {
- -
- setSearch(event.target.value)} - placeholder="Suche nach Titel oder IMDb" - /> - setStatus(event.value)} - placeholder="Status" - /> -
- -
- openDetail(event.data)} - className="clickable-table" - emptyMessage="Keine Einträge" - responsiveLayout="scroll" - > - - - - - - - - -
-
-
- - { - setDetailVisible(false); - setDetailLoading(false); - setLogLoadingMode(null); - }} - onAssignOmdb={openMetadataAssignDialog} - onAssignCdMetadata={openCdMetadataAssignDialog} - onResumeReady={handleResumeReady} - onRestartEncode={handleRestartEncode} - onRestartReview={handleRestartReview} - onReencode={handleReencode} - onDeleteFiles={handleDeleteFiles} - onDeleteEntry={handleDeleteEntry} - onRemoveFromQueue={handleRemoveFromQueue} - isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))} - omdbAssignBusy={metadataDialogBusy} - cdMetadataAssignBusy={cdMetadataDialogBusy} - actionBusy={actionBusy} - reencodeBusy={reencodeBusyJobId === selectedJob?.id} - deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id} - /> - - setMetadataDialogVisible(false)} - onSubmit={handleMetadataAssignSubmit} - onSearch={handleOmdbSearch} - busy={metadataDialogBusy} - /> - - { - setCdMetadataDialogVisible(false); - setCdMetadataDialogContext(null); - }} - onSubmit={handleCdMetadataAssignSubmit} - onSearch={handleMusicBrainzSearch} - onFetchRelease={handleMusicBrainzReleaseFetch} - busy={cdMetadataDialogBusy} - />
); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 6550f21..e5bb83f 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1723,6 +1723,72 @@ body { margin-top: 1rem; } +/* Drive devices list editor (Settings page) */ +.drive-devices-editor { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.drive-devices-empty { + color: var(--rip-muted); + font-size: 0.85rem; + margin: 0; +} + +.drive-device-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.drive-device-input { + flex: 1; + font-family: monospace; + font-size: 0.9rem; +} + +/* Per-drive CD sections in Dashboard */ +.cd-drives-section { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--rip-border); +} + +.cd-drive-item { + display: grid; + gap: 0.3rem; + padding: 0.5rem 0.6rem; + border: 1px solid var(--rip-border); + border-radius: 0.5rem; + background: var(--rip-panel-soft); +} + +.cd-drive-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.cd-drive-path { + font-size: 0.8rem; + color: var(--rip-muted); +} + +.cd-drive-disc-label { + font-size: 0.85rem; +} + +.cd-drive-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + .notification-toggle-box { display: flex; flex-direction: column; diff --git a/package-lock.json b/package-lock.json index 9305a97..950e155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.10.2-25", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.10.2-25", + "version": "0.11.0", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index f10c5fb..54160fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.10.2-25", + "version": "0.11.0", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",