From 5cf869eaca2de64f751ca2aca4b434363d044718 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 12 Mar 2026 10:15:50 +0000 Subject: [PATCH] Prototype --- backend/src/routes/pipelineRoutes.js | 15 + backend/src/services/cdRipService.js | 441 ++++++++++++++--- backend/src/services/diskDetectionService.js | 28 +- backend/src/services/musicBrainzService.js | 42 +- backend/src/services/pipelineService.js | 273 ++++++++-- db/schema.sql | 16 + frontend/src/api/client.js | 3 + frontend/src/components/CdMetadataDialog.jsx | 198 ++++---- frontend/src/components/CdRipConfigPanel.jsx | 492 +++++++++++++++++-- frontend/src/pages/DashboardPage.jsx | 67 ++- frontend/src/styles/app.css | 99 ++++ install-dev.sh | 3 + install.sh | 3 + 13 files changed, 1440 insertions(+), 240 deletions(-) diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index f98df5e..0b721f8 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -56,6 +56,21 @@ router.get( }) ); +router.get( + '/cd/musicbrainz/release/:mbId', + asyncHandler(async (req, res) => { + const mbId = String(req.params.mbId || '').trim(); + if (!mbId) { + const error = new Error('mbId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId }); + const release = await pipelineService.getMusicBrainzReleaseById(mbId); + res.json({ release }); + }) +); + router.post( '/cd/select-metadata', asyncHandler(async (req, res) => { diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index 1235854..a40116a 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); const { promisify } = require('util'); -const settingsService = require('./settingsService'); const logger = require('./logger').child('CD_RIP'); const { spawnTrackedProcess } = require('./processRunner'); const { parseCdParanoiaProgress } = require('../utils/progressParsers'); @@ -12,35 +11,80 @@ const { errorToMeta } = require('../utils/errorMeta'); const execFileAsync = promisify(execFile); const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']); +const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}'; /** - * Parse cdparanoia -Q output (stderr) to extract track information. - * Example output: - * Table of contents (start sector, length [start sector, length]): - * track 1: 0 (00:00.00) 24218 (05:22.43) - * track 2: 24218 (05:22.43) 15120 (03:21.20) - * TOTAL 193984 (43:04.59) + * Parse cdparanoia -Q output to extract track information. + * Supports both bracket styles shown by different builds: + * track 1: 0 (00:00.00) 24218 (05:22.43) + * track 1: 0 [00:00.00] 24218 [05:22.43] */ function parseToc(tocOutput) { const lines = String(tocOutput || '').split(/\r?\n/); const tracks = []; + for (const line of lines) { - const m = line.match(/^\s*track\s+(\d+)\s*:\s*(\d+)\s+\((\d+):(\d+)\.(\d+)\)\s+(\d+)\s+\((\d+):(\d+)\.(\d+)\)/i); - if (!m) { + const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i); + if (trackMatch) { + const position = Number(trackMatch[1]); + const payloadWithoutTimes = String(trackMatch[2] || '') + .replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' '); + const sectorValues = payloadWithoutTimes.match(/\d+/g) || []; + if (sectorValues.length < 2) { + continue; + } + + const startSector = Number(sectorValues[0]); + const lengthSector = Number(sectorValues[1]); + if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) { + continue; + } + if (position <= 0 || startSector < 0 || lengthSector <= 0) { + continue; + } + + // duration in seconds: sectors / 75 + const durationSec = Math.round(lengthSector / 75); + tracks.push({ + position, + startSector, + lengthSector, + durationSec, + durationMs: durationSec * 1000 + }); continue; } - const startSector = Number(m[2]); - const lengthSector = Number(m[6]); - // duration in seconds: sectors / 75 + + // Alternative cdparanoia -Q table style: + // 1. 16503 [03:40.03] 0 [00:00.00] no no 2 + // ^ length sectors ^ start sector + const tableMatch = line.match( + /^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i + ); + if (!tableMatch) { + continue; + } + + const position = Number(tableMatch[1]); + const lengthSector = Number(tableMatch[2]); + const startSector = Number(tableMatch[3]); + if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) { + continue; + } + if (position <= 0 || startSector < 0 || lengthSector <= 0) { + continue; + } + const durationSec = Math.round(lengthSector / 75); tracks.push({ - position: Number(m[1]), + position, startSector, lengthSector, durationSec, durationMs: durationSec * 1000 }); } + return tracks; } @@ -48,19 +92,20 @@ async function readToc(devicePath, cmd) { const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia'; logger.info('toc:read', { devicePath, cmd: cdparanoia }); try { - // cdparanoia -Q writes to stderr, exits 0 on success - const { stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], { + // Depending on distro/build, TOC can appear on stderr and/or stdout. + const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], { timeout: 15000 }); - const tracks = parseToc(stderr); + const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); logger.info('toc:done', { devicePath, trackCount: tracks.length }); return tracks; } catch (error) { - // cdparanoia -Q exits non-zero sometimes even on success; try parsing stderr + // cdparanoia -Q may exit non-zero even when TOC is readable. const stderr = String(error?.stderr || ''); - const tracks = parseToc(stderr); + const stdout = String(error?.stdout || ''); + const tracks = parseToc(`${stderr}\n${stdout}`); if (tracks.length > 0) { - logger.info('toc:done-from-stderr', { devicePath, trackCount: tracks.length }); + logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length }); return tracks; } logger.warn('toc:failed', { devicePath, error: errorToMeta(error) }); @@ -68,21 +113,247 @@ async function readToc(devicePath, cmd) { } } -function buildOutputFilename(track, meta, format) { - const ext = format === 'wav' ? 'wav' : format; - const num = String(track.position).padStart(2, '0'); - const trackTitle = (track.title || `Track ${track.position}`) - .replace(/[/\\?%*:|"<>]/g, '-') - .trim(); - return `${num}. ${trackTitle}.${ext}`; +function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate); + const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav'; + return `${relativeBasePath}.${ext}`; } -function buildOutputDir(meta, baseDir) { - const artist = (meta?.artist || 'Unknown Artist').replace(/[/\\?%*:|"<>]/g, '-').trim(); - const album = (meta?.title || 'Unknown Album').replace(/[/\\?%*:|"<>]/g, '-').trim(); - const year = meta?.year ? ` (${meta.year})` : ''; - const folderName = `${artist} - ${album}${year}`; - return path.join(baseDir, folderName); +function sanitizePathSegment(value, fallback = 'unknown') { + const raw = String(value == null ? '' : value) + .normalize('NFC') + .replace(/[\\/:*?"<>|]/g, '-') + // Keep umlauts/special letters, but filter heart symbols in filenames. + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!raw || raw === '.' || raw === '..') { + return fallback; + } + return raw.slice(0, 180); +} + +function normalizeTemplateTokenKey(rawKey) { + const key = String(rawKey || '').trim().toLowerCase(); + if (!key) { + return ''; + } + if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') { + return 'trackNr'; + } + if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') { + return 'trackNo'; + } + if (key === 'trackartist' || key === 'track_artist') { + return 'trackArtist'; + } + if (key === 'albumartist') { + return 'albumArtist'; + } + if (key === 'interpret') { + return 'artist'; + } + return key; +} + +function cleanupRenderedTemplate(value) { + return String(value || '') + .replace(/\(\s*\)/g, '') + .replace(/\[\s*]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function renderOutputTemplate(template, values) { + const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE; + const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => { + const normalizedKey = normalizeTemplateTokenKey(keyA || keyB); + const rawValue = values[normalizedKey]; + if (rawValue === undefined || rawValue === null) { + return ''; + } + return String(rawValue); + }); + return cleanupRenderedTemplate(rendered); +} + +function buildTemplateValues(track, meta, format = null) { + const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1; + const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`); + const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist'); + const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist'); + const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album'); + const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), ''); + return { + artist: albumArtist, + albumArtist, + trackArtist, + album, + year, + title: trackTitle, + trackNr: String(trackNo).padStart(2, '0'), + trackNo: String(trackNo), + format: format ? String(format).trim().toLowerCase() : '' + }; +} + +function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) { + const values = buildTemplateValues(track, meta, format); + const rendered = renderOutputTemplate(outputTemplate, values) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, ''); + + const parts = rendered + .split('/') + .map((part) => sanitizePathSegment(part, 'unknown')) + .filter(Boolean); + + if (parts.length === 0) { + return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`; + } + + return path.join(...parts); +} + +function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const sampleTrack = { + position: 1, + title: 'Track 1' + }; + const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate); + const relativeDir = path.dirname(relativeBasePath); + if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) { + return baseDir; + } + return path.join(baseDir, relativeDir); +} + +function splitPathSegments(value) { + return String(value || '') + .replace(/\\/g, '/') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) { + const outputSegments = splitPathSegments(outputBaseDir); + const relativeSegments = splitPathSegments(relativeDir); + if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) { + return false; + } + const offset = outputSegments.length - relativeSegments.length; + for (let i = 0; i < relativeSegments.length; i++) { + if (outputSegments[offset + i] !== relativeSegments[i]) { + return false; + } + } + return true; +} + +function stripLeadingRelativeDir(relativeFilePath, relativeDir) { + const fileSegments = splitPathSegments(relativeFilePath); + const dirSegments = splitPathSegments(relativeDir); + if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) { + return relativeFilePath; + } + for (let i = 0; i < dirSegments.length; i++) { + if (fileSegments[i] !== dirSegments[i]) { + return relativeFilePath; + } + } + return path.join(...fileSegments.slice(dirSegments.length)); +} + +function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format); + const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav'; + const relativeDir = path.dirname(relativeBasePath); + let relativeFilePath = `${relativeBasePath}.${ext}`; + if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) { + if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) { + relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir); + } + } + const outFile = path.join(outputBaseDir, relativeFilePath); + return { + outFile, + relativeFilePath, + outFilename: path.basename(relativeFilePath) + }; +} + +function buildCancelledError() { + const error = new Error('Job wurde vom Benutzer abgebrochen.'); + error.statusCode = 409; + return error; +} + +function assertNotCancelled(isCancelled) { + if (typeof isCancelled === 'function' && isCancelled()) { + throw buildCancelledError(); + } +} + +function normalizeExitCode(error) { + const code = Number(error?.code); + if (Number.isFinite(code)) { + return Math.trunc(code); + } + return 1; +} + +function quoteShellArg(value) { + const text = String(value == null ? '' : value); + if (!text) { + return "''"; + } + if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) { + return text; + } + return `'${text.replace(/'/g, "'\\''")}'`; +} + +function formatCommandLine(cmd, args = []) { + const normalizedArgs = Array.isArray(args) ? args : []; + return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' '); +} + +async function runProcessTracked({ + cmd, + args, + cwd, + onStdoutLine, + onStderrLine, + context, + onProcessHandle, + isCancelled +}) { + assertNotCancelled(isCancelled); + const handle = spawnTrackedProcess({ + cmd, + args, + cwd, + onStdoutLine, + onStderrLine, + context + }); + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + try { + return await handle.promise; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + throw buildCancelledError(); + } + throw error; + } } /** @@ -99,8 +370,11 @@ function buildOutputDir(meta, baseDir) { * @param {number[]} options.selectedTracks - track positions to rip (empty = all) * @param {object[]} options.tracks - TOC track list [{position, durationMs, title}] * @param {object} options.meta - album metadata {title, artist, year} + * @param {string} options.outputTemplate - template for relative output path without extension * @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void * @param {Function} options.onLog - (level, msg) => void + * @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration + * @param {Function} options.isCancelled - returns true when user requested cancellation * @param {object} options.context - passed to spawnTrackedProcess */ async function ripAndEncode(options) { @@ -115,8 +389,11 @@ async function ripAndEncode(options) { selectedTracks = [], tracks = [], meta = {}, + outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, onProgress, onLog, + onProcessHandle, + isCancelled, context } = options; @@ -149,37 +426,42 @@ async function ripAndEncode(options) { // ── Phase 1: Rip each selected track to WAV ────────────────────────────── for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); const track = tracksToRip[i]; const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + const ripArgs = ['-d', devicePath, String(track.position), wavFile]; - log('info', `Rippe Track ${track.position} von ${tracks.length} …`); + log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`); + log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`); - let lastTrackPercent = 0; - - const runInfo = await spawnTrackedProcess({ - cmd: cdparanoiaCmd, - args: ['-d', devicePath, String(track.position), wavFile], - cwd: rawWavDir, - onStderrLine(line) { - const parsed = parseCdParanoiaProgress(line); - if (parsed && parsed.percent !== null) { - lastTrackPercent = parsed.percent; - const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50; - onProgress && onProgress({ - phase: 'rip', - trackIndex: i + 1, - trackTotal: tracksToRip.length, - trackPosition: track.position, - percent: overallPercent - }); - } - }, - context - }); - - if (runInfo.exitCode !== 0) { + try { + await runProcessTracked({ + cmd: cdparanoiaCmd, + args: ripArgs, + cwd: rawWavDir, + onStderrLine(line) { + const parsed = parseCdParanoiaProgress(line); + if (parsed && parsed.percent !== null) { + const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50; + onProgress && onProgress({ + phase: 'rip', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + percent: overallPercent + }); + } + }, + context, + onProcessHandle, + isCancelled + }); + } catch (error) { + if (String(error?.message || '').toLowerCase().includes('abgebrochen')) { + throw error; + } throw new Error( - `cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${runInfo.exitCode})` + `cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})` ); } @@ -198,9 +480,12 @@ async function ripAndEncode(options) { if (format === 'wav') { // Just move WAV files to output dir with proper names for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); const track = tracksToRip[i]; const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); - const outFile = path.join(outputDir, buildOutputFilename(track, meta, 'wav')); + const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate); + ensureDir(path.dirname(outFile)); + log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`); fs.renameSync(wavFile, outFile); onProgress && onProgress({ phase: 'encode', @@ -215,6 +500,7 @@ async function ripAndEncode(options) { } for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); const track = tracksToRip[i]; const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); @@ -222,21 +508,33 @@ async function ripAndEncode(options) { throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`); } - const outFilename = buildOutputFilename(track, meta, format); - const outFile = path.join(outputDir, outFilename); + const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate); + ensureDir(path.dirname(outFile)); log('info', `Encodiere Track ${track.position} → ${outFilename} …`); const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile); + log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`); - await spawnTrackedProcess({ - cmd: encodeArgs.cmd, - args: encodeArgs.args, - cwd: rawWavDir, - onStdoutLine() {}, - onStderrLine() {}, - context - }); + try { + await runProcessTracked({ + cmd: encodeArgs.cmd, + args: encodeArgs.args, + cwd: rawWavDir, + onStdoutLine() {}, + onStderrLine() {}, + context, + onProcessHandle, + isCancelled + }); + } catch (error) { + if (String(error?.message || '').toLowerCase().includes('abgebrochen')) { + throw error; + } + throw new Error( + `${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})` + ); + } // Clean up WAV after encode try { @@ -260,7 +558,7 @@ async function ripAndEncode(options) { } function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) { - const artist = meta?.artist || ''; + const artist = track?.artist || meta?.artist || ''; const album = meta?.title || ''; const year = meta?.year ? String(meta.year) : ''; const trackTitle = track.title || `Track ${track.position}`; @@ -346,8 +644,11 @@ function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) { } module.exports = { + parseToc, readToc, ripAndEncode, buildOutputDir, + buildOutputFilename, + DEFAULT_CD_OUTPUT_TEMPLATE, SUPPORTED_FORMATS }; diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index 6e3b8fb..c77ec9c 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -4,6 +4,7 @@ const { execFile } = require('child_process'); const { promisify } = require('util'); const settingsService = require('./settingsService'); const logger = require('./logger').child('DISK'); +const { parseToc } = require('./cdRipService'); const { errorToMeta } = require('../utils/errorMeta'); const execFileAsync = promisify(execFile); @@ -541,15 +542,28 @@ class DiskDetectionService extends EventEmitter { // Last resort: cdparanoia can read the TOC of audio CDs directly. // Useful when udev media flags are not propagated (e.g. VM passthrough). - // blkid already returned nothing above, so a successful cdparanoia -Q - // means an audio CD is present (data discs have no filesystem but blkid - // would still have caught them via sector probing). + // Some builds return non-zero even when TOC output exists, so parse both + // stdout/stderr and treat valid TOC lines as "audio CD present". + // Keep compatibility with previous behavior: exit 0 counts as media even + // when TOC output format cannot be parsed. try { - await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 }); - logger.debug('cdparanoia:audio-cd', { devicePath }); + const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 }); + const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length }); + return { hasMedia: true, type: 'audio_cd' }; + } + logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath }); return { hasMedia: true, type: 'audio_cd' }; - } catch (_cdError) { - // cdparanoia failed – no audio CD present (or cdparanoia not installed) + } catch (cdError) { + const stderr = String(cdError?.stderr || ''); + const stdout = String(cdError?.stdout || ''); + const tracks = parseToc(`${stderr}\n${stdout}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length }); + return { hasMedia: true, type: 'audio_cd' }; + } + // cdparanoia failed and no TOC output could be parsed. } logger.debug('blkid:no-media-or-fail', { devicePath }); diff --git a/backend/src/services/musicBrainzService.js b/backend/src/services/musicBrainzService.js index 96bf1a0..62062d1 100644 --- a/backend/src/services/musicBrainzService.js +++ b/backend/src/services/musicBrainzService.js @@ -39,14 +39,40 @@ function normalizeRelease(release) { const year = yearMatch ? Number(yearMatch[1]) : null; const media = Array.isArray(release.media) ? release.media : []; - const tracks = media.flatMap((medium, mediumIdx) => { + const normalizedTracks = media.flatMap((medium, mediumIdx) => { const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : []; - return mediumTracks.map((track) => ({ - position: Number(track.position || mediumIdx * 100 + 1), + return mediumTracks.map((track, trackIdx) => { + const rawPosition = String(track.position || track.number || '').trim(); + const parsedPosition = Number.parseInt(rawPosition, 10); + const fallbackPosition = mediumIdx * 100 + trackIdx + 1; + const position = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? parsedPosition + : fallbackPosition; + return { + position, number: String(track.number || track.position || ''), title: String(track.title || ''), - durationMs: Number(track.length || 0) || null - })); + durationMs: Number(track.length || 0) || null, + rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [], + rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : [] + }; + }); + }).map((track) => { + const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit) + ? track.rawTrackArtistCredit + : []; + const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit) + ? track.rawRecordingArtistCredit + : []; + const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + return { + position: track.position, + number: track.number, + title: track.title, + durationMs: track.durationMs, + artist: artistFromTrack || artistFromRecording || artistCredit || null + }; }); // Always generate the CAA URL when an id is present; the browser/onError @@ -66,7 +92,7 @@ function normalizeRelease(release) { ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null : null, coverArtUrl, - tracks + tracks: normalizedTracks }; } @@ -93,7 +119,7 @@ class MusicBrainzService { url.searchParams.set('query', q); url.searchParams.set('fmt', 'json'); url.searchParams.set('limit', '10'); - url.searchParams.set('inc', 'artist-credits+labels+recordings'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); try { const data = await mbFetch(url.toString()); @@ -126,7 +152,7 @@ class MusicBrainzService { const url = new URL(`${MB_BASE}/release/${id}`); url.searchParams.set('fmt', 'json'); - url.searchParams.set('inc', 'artist-credits+labels+recordings+cover-art-archive'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); try { const data = await mbFetch(url.toString()); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index adf39fb..fda768e 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -74,6 +74,16 @@ function nowIso() { return new Date().toISOString(); } +function normalizeCdTrackText(value) { + return String(value || '') + .normalize('NFC') + // Keep umlauts/special letters, but strip heart symbols from imported metadata. + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + function normalizeMediaProfile(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { @@ -9793,11 +9803,17 @@ class PipelineService extends EventEmitter { const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length }); + if (!tracks.length) { + const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).'); + error.statusCode = 400; + throw error; + } const cdInfo = { phase: 'PREPARE', mediaProfile: 'cd', preparedAt: nowIso(), + cdparanoiaCmd, tracks, detectedTitle }; @@ -9817,6 +9833,8 @@ class PipelineService extends EventEmitter { 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, @@ -9826,6 +9844,9 @@ class PipelineService extends EventEmitter { jobId: job.id, device, mediaProfile: 'cd', + devicePath, + cdparanoiaCmd, + cdparanoiaCommandPreview, detectedTitle, tracks } @@ -9847,6 +9868,24 @@ class PipelineService extends EventEmitter { return results; } + async getMusicBrainzReleaseById(mbId) { + const id = String(mbId || '').trim(); + if (!id) { + const error = new Error('mbId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('musicbrainz:get-by-id', { mbId: id }); + const release = await musicBrainzService.getReleaseById(id); + if (!release) { + const error = new Error(`MusicBrainz Release ${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + logger.info('musicbrainz:get-by-id:done', { mbId: id, trackCount: Array.isArray(release.tracks) ? release.tracks.length : 0 }); + return release; + } + async selectCdMetadata(payload) { const { jobId, @@ -9881,9 +9920,12 @@ class PipelineService extends EventEmitter { const selected = Array.isArray(selectedTracks) ? selectedTracks.find((st) => Number(st.position) === Number(t.position)) : null; + const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`; + const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null; return { ...t, - title: selected?.title || t.title || `Track ${t.position}`, + title: resolvedTitle, + artist: resolvedArtist, selected: selected ? Boolean(selected.selected) : true }; }); @@ -9909,6 +9951,10 @@ class PipelineService extends EventEmitter { ); 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, progress: 0, @@ -9919,7 +9965,10 @@ class PipelineService extends EventEmitter { jobId, mediaProfile: 'cd', tracks: mergedTracks, - selectedMetadata: { title, artist, year, mbId, coverUrl } + selectedMetadata: { title, artist, year, mbId, coverUrl }, + devicePath: resolvedDevicePath, + cdparanoiaCmd: resolvedCdparanoiaCmd, + cdparanoiaCommandPreview } }); } @@ -9949,27 +9998,119 @@ class PipelineService extends EventEmitter { const format = String(ripConfig?.format || 'flac').trim().toLowerCase(); const formatOptions = ripConfig?.formatOptions || {}; + const normalizeTrackPosition = (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks) - ? ripConfig.selectedTracks.map(Number).filter(Number.isFinite) + ? ripConfig.selectedTracks + .map(normalizeTrackPosition) + .filter((value) => Number.isFinite(value) && value > 0) : []; + const normalizeOptionalYear = (value) => { + if (value === null || value === undefined || String(value).trim() === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const incomingTracks = Array.isArray(ripConfig?.tracks) ? ripConfig.tracks : []; + const incomingByPosition = new Map(); + for (const incoming of incomingTracks) { + const position = normalizeTrackPosition(incoming?.position); + if (!position) { + continue; + } + incomingByPosition.set(position, incoming); + } const selectedMeta = cdInfo.selectedMetadata || {}; + const incomingMeta = ripConfig?.metadata && typeof ripConfig.metadata === 'object' + ? ripConfig.metadata + : {}; + const effectiveSelectedMeta = { + ...selectedMeta, + title: normalizeCdTrackText(incomingMeta?.title) + || normalizeCdTrackText(selectedMeta?.title) + || normalizeCdTrackText(job?.title) + || normalizeCdTrackText(cdInfo?.detectedTitle) + || 'Audio CD', + artist: normalizeCdTrackText(incomingMeta?.artist) + || normalizeCdTrackText(selectedMeta?.artist) + || null, + year: normalizeOptionalYear(incomingMeta?.year) + ?? normalizeOptionalYear(selectedMeta?.year) + ?? normalizeOptionalYear(job?.year) + ?? null + }; + const mergedTracks = tocTracks.map((track) => { + const position = normalizeTrackPosition(track?.position); + if (!position) { + return null; + } + const incoming = incomingByPosition.get(position) || null; + const fallbackTitle = normalizeCdTrackText(track?.title) || `Track ${position}`; + const fallbackArtist = normalizeCdTrackText(track?.artist) || normalizeCdTrackText(effectiveSelectedMeta?.artist) || ''; + const title = normalizeCdTrackText(incoming?.title) || fallbackTitle; + const artist = normalizeCdTrackText(incoming?.artist) || fallbackArtist || null; + const selected = incoming + ? Boolean(incoming?.selected) + : (track?.selected !== false); + return { + ...track, + position, + title, + artist, + selected + }; + }).filter(Boolean); + + const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0 + ? selectedTrackPositions + : mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position); const settings = await settingsService.getEffectiveSettingsMap('cd'); const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; - const rawBaseDir = String(settings.raw_dir || 'data/output/raw').trim(); + const cdOutputTemplate = String( + settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE + ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const cdBaseDir = String(settings.raw_dir_cd || '').trim() || 'data/output/cd'; const jobDir = `CD_Job${jobId}_${Date.now()}`; - const rawWavDir = path.join(rawBaseDir, jobDir, 'wav'); - const outputDir = cdRipService.buildOutputDir(selectedMeta, path.join(rawBaseDir, jobDir)); + const rawWavDir = path.join(cdBaseDir, '.tmp', jobDir, 'wav'); + const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate); + const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; + const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); + const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath} ${previewTrackPos} ${previewWavPath}`; + + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: effectiveSelectedMeta + }; await historyService.updateJob(jobId, { + title: effectiveSelectedMeta?.title || null, + year: normalizeOptionalYear(effectiveSelectedMeta?.year), status: 'CD_RIPPING', last_state: 'CD_RIPPING', error_message: null, - raw_path: rawWavDir, + raw_path: null, output_path: outputDir, - encode_plan_json: JSON.stringify({ format, formatOptions, selectedTracks: selectedTrackPositions }) + makemkv_info_json: JSON.stringify(updatedCdInfo), + encode_plan_json: JSON.stringify({ + format, + formatOptions, + selectedTracks: effectiveSelectedTrackPositions, + tracks: mergedTracks, + outputTemplate: cdOutputTemplate + }) }); await this.setState('CD_RIPPING', { @@ -9981,16 +10122,25 @@ class PipelineService extends EventEmitter { ...(this.snapshot.context || {}), jobId, mediaProfile: 'cd', - selectedMetadata: selectedMeta + tracks: mergedTracks, + selectedMetadata: effectiveSelectedMeta, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputTemplate: cdOutputTemplate, + cdparanoiaCommandPreview } }); - logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: selectedTrackPositions.length }); - await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip gestartet: Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}`); + logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: effectiveSelectedTrackPositions.length }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + ); // Run asynchronously so the HTTP response returns immediately this._runCdRip({ - job, jobId, devicePath, cdparanoiaCmd, @@ -9998,9 +10148,10 @@ class PipelineService extends EventEmitter { outputDir, format, formatOptions, - selectedTrackPositions, - tocTracks, - selectedMeta + outputTemplate: cdOutputTemplate, + selectedTrackPositions: effectiveSelectedTrackPositions, + tocTracks: mergedTracks, + selectedMeta: effectiveSelectedMeta }).catch((error) => { logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) }); }); @@ -10009,7 +10160,6 @@ class PipelineService extends EventEmitter { } async _runCdRip({ - job, jobId, devicePath, cdparanoiaCmd, @@ -10017,14 +10167,54 @@ class PipelineService extends EventEmitter { outputDir, format, formatOptions, + outputTemplate, selectedTrackPositions, tocTracks, selectedMeta }) { const processKey = Number(jobId); - this.activeProcesses.set(processKey, { cancel: () => {} }); + let currentProcessHandle = null; + let lifecycleResolve = null; + let lifecycleSettled = false; + const lifecyclePromise = new Promise((resolve) => { + lifecycleResolve = resolve; + }); + const settleLifecycle = () => { + if (lifecycleSettled) { + return; + } + lifecycleSettled = true; + lifecycleResolve({ settled: true }); + }; + const sharedHandle = { + child: null, + promise: lifecyclePromise, + cancel: () => { + try { + currentProcessHandle?.cancel?.(); + } catch (_error) { + // ignore cancel race errors + } + } + }; + this.activeProcesses.set(processKey, sharedHandle); + this.syncPrimaryActiveProcess(); try { + let encodeStateApplied = false; + let lastProgressPercent = 0; + const bindProcessHandle = (handle) => { + currentProcessHandle = handle && typeof handle === 'object' ? handle : null; + sharedHandle.child = currentProcessHandle?.child || null; + this.syncPrimaryActiveProcess(); + if (this.cancelRequestedByJob.has(processKey)) { + try { + currentProcessHandle?.cancel?.(); + } catch (_error) { + // ignore cancel race errors + } + } + }; await cdRipService.ripAndEncode({ jobId, devicePath, @@ -10033,34 +10223,49 @@ class PipelineService extends EventEmitter { outputDir, format, formatOptions, + outputTemplate, selectedTracks: selectedTrackPositions, tracks: tocTracks, meta: selectedMeta, - onProgress: async ({ phase, percent }) => { - const clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); - const statusText = phase === 'rip' ? 'CD wird gerippt …' : 'Tracks werden encodiert …'; - const newState = phase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING'; + onProcessHandle: bindProcessHandle, + isCancelled: () => this.cancelRequestedByJob.has(processKey), + onProgress: async ({ phase, percent, trackIndex, trackTotal }) => { + const normalizedPhase = phase === 'encode' ? 'encode' : 'rip'; + const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING'; + let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); + if (normalizedPhase === 'rip') { + clampedPercent = Math.min(clampedPercent, 50); + } else { + clampedPercent = Math.max(50, clampedPercent); + } + if (clampedPercent < lastProgressPercent) { + clampedPercent = lastProgressPercent; + } + lastProgressPercent = clampedPercent; - if (phase === 'encode' && this.snapshot.state === 'CD_RIPPING') { + if (normalizedPhase === 'encode' && !encodeStateApplied) { + encodeStateApplied = true; await historyService.updateJob(jobId, { status: 'CD_ENCODING', last_state: 'CD_ENCODING' }); } - await this.setState(newState, { - activeJobId: jobId, - progress: clampedPercent, - eta: null, - statusText, - context: this.snapshot.context - }); + const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0 + ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})` + : ''; + const statusText = normalizedPhase === 'rip' + ? `CD wird gerippt …${detail}` + : `Tracks werden encodiert …${detail}`; + + await this.updateProgress(stage, clampedPercent, null, statusText, processKey); }, onLog: async (level, msg) => { await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); }, context: { jobId: processKey } }); + settleLifecycle(); // Success await historyService.updateJob(jobId, { @@ -10090,10 +10295,20 @@ class PipelineService extends EventEmitter { message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` }); } catch (error) { + settleLifecycle(); logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error); } finally { + try { + const cdTempJobDir = path.dirname(String(rawWavDir || '')); + if (cdTempJobDir && cdTempJobDir !== '.' && cdTempJobDir !== path.sep) { + fs.rmSync(cdTempJobDir, { recursive: true, force: true }); + } + } catch (_cleanupError) { + // ignore temp cleanup issues + } this.activeProcesses.delete(processKey); + this.syncPrimaryActiveProcess(); } } diff --git a/db/schema.sql b/db/schema.sql index c0da2f2..3af1dc0 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -345,6 +345,22 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ( + 'cd_output_template', + 'Tools', + 'CD Output Template', + 'string', + 1, + 'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt.', + '{artist} - {album} ({year})/{trackNr} {artist} - {title}', + '[]', + '{"minLength":1}', + 235 +); +INSERT OR IGNORE INTO settings_values (key, value) +VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}'); + -- Pfade – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('raw_dir_cd', 'Pfade', 'CD Ausgabeordner', 'path', 0, 'Optionaler Ausgabeordner für geripppte CD-Dateien. Leer = Fallback auf "Raw Ausgabeordner".', '/opt/ripster/backend/data/output/cd', '[]', '{}', 104); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 10df1fa..70d2c23 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -276,6 +276,9 @@ export const api = { searchMusicBrainz(q) { return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); }, + getMusicBrainzRelease(mbId) { + return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`); + }, async selectCdMetadata(payload) { const result = await request('/pipeline/cd/select-metadata', { method: 'POST', diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx index ba757c5..121a4db 100644 --- a/frontend/src/components/CdMetadataDialog.jsx +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -1,14 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog } from 'primereact/dialog'; import { Button } from 'primereact/button'; import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; import { InputNumber } from 'primereact/inputnumber'; -import { Checkbox } from 'primereact/checkbox'; function CoverThumb({ url, alt }) { const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(false); + }, [url]); if (!url || failed) { return
-
; } @@ -17,16 +19,46 @@ function CoverThumb({ url, alt }) { src={url} alt={alt} className="poster-thumb-lg" + loading="eager" + decoding="sync" onError={() => setFailed(true)} /> ); } -function formatDurationMs(ms) { - const totalSec = Math.round((ms || 0) / 1000); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - return `${min}:${String(sec).padStart(2, '0')}`; +const COVER_PRELOAD_TIMEOUT_MS = 3000; + +function preloadCoverImage(url) { + const src = String(url || '').trim(); + if (!src) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const image = new Image(); + let settled = false; + const cleanup = () => { + image.onload = null; + image.onerror = null; + }; + const done = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS); + image.onload = () => { + window.clearTimeout(timer); + done(); + }; + image.onerror = () => { + window.clearTimeout(timer); + done(); + }; + image.src = src; + }); } export default function CdMetadataDialog({ @@ -35,20 +67,22 @@ export default function CdMetadataDialog({ onHide, onSubmit, onSearch, + onFetchRelease, busy }) { const [selected, setSelected] = useState(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); + const [searchBusy, setSearchBusy] = useState(false); + const searchRunRef = useRef(0); // Manual metadata inputs const [manualTitle, setManualTitle] = useState(''); const [manualArtist, setManualArtist] = useState(''); const [manualYear, setManualYear] = useState(null); - // Per-track title editing + // Track titles are pre-filled from MusicBrainz and edited in the next step. const [trackTitles, setTrackTitles] = useState({}); - const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set()); const tocTracks = Array.isArray(context?.tracks) ? context.tracks : []; @@ -57,20 +91,18 @@ export default function CdMetadataDialog({ return; } setSelected(null); - setQuery(context?.detectedTitle || ''); + setQuery(''); setManualTitle(context?.detectedTitle || ''); setManualArtist(''); setManualYear(null); setResults([]); + setSearchBusy(false); const titles = {}; - const positions = new Set(); for (const t of tocTracks) { titles[t.position] = t.title || `Track ${t.position}`; - positions.add(t.position); } setTrackTitles(titles); - setSelectedTrackPositions(positions); }, [visible, context]); useEffect(() => { @@ -100,48 +132,79 @@ export default function CdMetadataDialog({ }, [selected]); const handleSearch = async () => { - if (!query.trim()) { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { return; } - const searchResults = await onSearch(query.trim()); - setResults(searchResults || []); - setSelected(null); - }; - - const handleToggleTrack = (position) => { - setSelectedTrackPositions((prev) => { - const next = new Set(prev); - if (next.has(position)) { - next.delete(position); - } else { - next.add(position); + setSearchBusy(true); + const searchRunId = searchRunRef.current + 1; + searchRunRef.current = searchRunId; + try { + const searchResults = await onSearch(trimmedQuery); + const normalizedResults = Array.isArray(searchResults) ? searchResults : []; + await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl))); + if (searchRunRef.current !== searchRunId) { + return; + } + setResults(normalizedResults); + setSelected(null); + } finally { + if (searchRunRef.current === searchRunId) { + setSearchBusy(false); } - return next; - }); - }; - - const handleToggleAll = () => { - if (selectedTrackPositions.size === tocTracks.length) { - setSelectedTrackPositions(new Set()); - } else { - setSelectedTrackPositions(new Set(tocTracks.map((t) => t.position))); } }; const handleSubmit = async () => { - const tracks = tocTracks.map((t) => ({ - position: t.position, - title: trackTitles[t.position] || `Track ${t.position}`, - selected: selectedTrackPositions.has(t.position) - })); + const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + let releaseDetails = selected; + if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') { + const fetched = await onFetchRelease(selected.mbId); + if (fetched && typeof fetched === 'object') { + releaseDetails = fetched; + } + } + + const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : []; + const releaseTracksByPosition = new Map(); + releaseTracks.forEach((track, index) => { + const parsedPosition = Number(track?.position); + const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? Math.trunc(parsedPosition) + : index + 1; + if (!releaseTracksByPosition.has(normalizedPosition)) { + releaseTracksByPosition.set(normalizedPosition, track); + } + }); + + const tracks = tocTracks.map((t, index) => { + const position = Number(t.position); + const byPosition = releaseTracksByPosition.get(position); + const byIndex = releaseTracks[index]; + return { + position, + title: normalizeTrackText( + byPosition?.title + || byIndex?.title + || trackTitles[t.position] + ) || `Track ${t.position}`, + artist: normalizeTrackText( + byPosition?.artist + || byIndex?.artist + || manualArtist.trim() + || releaseDetails?.artist + ) || null, + selected: true + }; + }); const payload = { jobId: context.jobId, title: manualTitle.trim() || context?.detectedTitle || 'Audio CD', artist: manualArtist.trim() || null, year: manualYear || null, - mbId: selected?.mbId || null, - coverUrl: selected?.coverArtUrl || null, + mbId: releaseDetails?.mbId || selected?.mbId || null, + coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null, tracks }; @@ -159,9 +222,6 @@ export default function CdMetadataDialog({ ); - const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length; - const tracksBlocking = tocTracks.length > 0 && selectedTrackPositions.size === 0; - return ( e.key === 'Enter' && handleSearch()} placeholder="Album / Interpret suchen" /> - diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index cb01219..2f052df 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -4,7 +4,10 @@ import { Slider } from 'primereact/slider'; import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; +import { api } from '../api/client'; function isFieldVisible(field, values) { if (!field.showWhen) { @@ -51,6 +54,147 @@ function FormatField({ field, value, onChange }) { return null; } +function quoteShellArg(value) { + const text = String(value || ''); + if (!text) { + return "''"; + } + if (/^[a-zA-Z0-9_./:-]+$/.test(text)) { + return text; + } + return `'${text.replace(/'/g, "'\\''")}'`; +} + +function buildCommandLine(cmd, args = []) { + const normalizedArgs = Array.isArray(args) ? args : []; + return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' '); +} + +function buildEncodeCommandPreview({ + format, + formatOptions, + wavFile, + outFile, + trackTitle, + trackArtist, + albumTitle, + year, + trackNo +}) { + const normalizedFormat = String(format || '').trim().toLowerCase(); + const title = String(trackTitle || `Track ${trackNo || 1}`).trim() || `Track ${trackNo || 1}`; + const artist = String(trackArtist || '').trim(); + const album = String(albumTitle || '').trim(); + const releaseYear = year == null ? '' : String(year).trim(); + const number = String(trackNo || 1); + + if (normalizedFormat === 'wav') { + return buildCommandLine('mv', [wavFile, outFile]); + } + + if (normalizedFormat === 'flac') { + const level = Math.max(0, Math.min(8, Number(formatOptions?.flacCompression ?? 5))); + return buildCommandLine('flac', [ + `--compression-level-${level}`, + '--tag', `TITLE=${title}`, + '--tag', `ARTIST=${artist}`, + '--tag', `ALBUM=${album}`, + '--tag', `DATE=${releaseYear}`, + '--tag', `TRACKNUMBER=${number}`, + wavFile, + '-o', outFile + ]); + } + + if (normalizedFormat === 'mp3') { + const mode = String(formatOptions?.mp3Mode || 'cbr').trim().toLowerCase(); + const args = ['--id3v2-only', '--noreplaygain']; + if (mode === 'vbr') { + const quality = Math.max(0, Math.min(9, Number(formatOptions?.mp3Quality ?? 4))); + args.push('-V', String(quality)); + } else { + const bitrate = Number(formatOptions?.mp3Bitrate ?? 192); + args.push('-b', String(bitrate)); + } + args.push( + '--tt', title, + '--ta', artist, + '--tl', album, + '--ty', releaseYear, + '--tn', number, + wavFile, + outFile + ); + return buildCommandLine('lame', args); + } + + if (normalizedFormat === 'opus') { + const bitrate = Math.max(32, Math.min(512, Number(formatOptions?.opusBitrate ?? 160))); + const complexity = Math.max(0, Math.min(10, Number(formatOptions?.opusComplexity ?? 10))); + return buildCommandLine('opusenc', [ + '--bitrate', String(bitrate), + '--comp', String(complexity), + '--title', title, + '--artist', artist, + '--album', album, + '--date', releaseYear, + '--tracknumber', number, + wavFile, + outFile + ]); + } + + if (normalizedFormat === 'ogg') { + const quality = Math.max(-1, Math.min(10, Number(formatOptions?.oggQuality ?? 6))); + return buildCommandLine('oggenc', [ + '-q', String(quality), + '-t', title, + '-a', artist, + '-l', album, + '-d', releaseYear, + '-N', number, + '-o', outFile, + wavFile + ]); + } + + return ''; +} + +function normalizePosition(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeTrackText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function normalizeYear(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function formatTrackDuration(track) { + const durationMs = Number(track?.durationMs); + const durationSec = Number(track?.durationSec); + const totalSec = Number.isFinite(durationMs) && durationMs > 0 + ? Math.round(durationMs / 1000) + : (Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : 0); + if (totalSec <= 0) { + return '-'; + } + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${String(sec).padStart(2, '0')}`; +} + export default function CdRipConfigPanel({ pipeline, onStart, @@ -67,27 +211,117 @@ export default function CdRipConfigPanel({ const [format, setFormat] = useState('flac'); const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac')); + const [settingsCdparanoiaCmd, setSettingsCdparanoiaCmd] = useState(''); // Track selection: position → boolean const [selectedTracks, setSelectedTracks] = useState(() => { const map = {}; for (const t of tracks) { - map[t.position] = true; + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + map[position] = t?.selected !== false; } return map; }); + // Editable track metadata in job overview (artist/title). + const [trackFields, setTrackFields] = useState(() => { + const map = {}; + const defaultArtist = normalizeTrackText(selectedMeta?.artist); + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + const fallbackTitle = `Track ${position}`; + map[position] = { + title: normalizeTrackText(t?.title) || fallbackTitle, + artist: normalizeTrackText(t?.artist) || defaultArtist + }; + } + return map; + }); + const [metaFields, setMetaFields] = useState(() => ({ + title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '', + artist: normalizeTrackText(selectedMeta?.artist) || '', + year: normalizeYear(selectedMeta?.year) + })); useEffect(() => { setFormatOptions(getDefaultFormatOptions(format)); }, [format]); useEffect(() => { - const map = {}; - for (const t of tracks) { - map[t.position] = selectedTracks[t.position] !== false; - } - setSelectedTracks(map); - }, [tracks.length]); + setMetaFields({ + title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '', + artist: normalizeTrackText(selectedMeta?.artist) || '', + year: normalizeYear(selectedMeta?.year) + }); + }, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]); + + useEffect(() => { + let cancelled = false; + const refreshSettings = async () => { + try { + const response = await api.getSettings({ forceRefresh: true }); + if (cancelled) { + return; + } + const value = String(response?.settings?.cdparanoia_command || '').trim(); + setSettingsCdparanoiaCmd(value); + } catch (_error) { + if (!cancelled) { + setSettingsCdparanoiaCmd(''); + } + } + }; + refreshSettings(); + const intervalId = setInterval(refreshSettings, 5000); + return () => { + cancelled = true; + clearInterval(intervalId); + }; + }, [context?.jobId]); + + useEffect(() => { + setSelectedTracks((prev) => { + const next = {}; + for (const t of tracks) { + const normalized = normalizePosition(t?.position); + if (!normalized) { + continue; + } + if (prev[normalized] !== undefined) { + next[normalized] = prev[normalized]; + } else { + next[normalized] = t?.selected !== false; + } + } + return next; + }); + }, [tracks]); + + useEffect(() => { + const defaultArtist = normalizeTrackText(selectedMeta?.artist); + setTrackFields((prev) => { + const next = {}; + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + const previous = prev[position] || {}; + const fallbackTitle = normalizeTrackText(t?.title) || `Track ${position}`; + const fallbackArtist = normalizeTrackText(t?.artist) || defaultArtist; + next[position] = { + title: normalizeTrackText(previous.title) || fallbackTitle, + artist: normalizeTrackText(previous.artist) || fallbackArtist + }; + } + return next; + }); + }, [tracks, selectedMeta?.artist]); const handleFormatOptionChange = (key, value) => { setFormatOptions((prev) => ({ ...prev, [key]: value })); @@ -98,17 +332,73 @@ export default function CdRipConfigPanel({ }; const handleToggleAll = () => { - const allSelected = tracks.every((t) => selectedTracks[t.position] !== false); + const allSelected = tracks.every((t) => { + const position = normalizePosition(t?.position); + return position ? selectedTracks[position] !== false : false; + }); const map = {}; for (const t of tracks) { - map[t.position] = !allSelected; + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + map[position] = !allSelected; } setSelectedTracks(map); }; + const handleTrackFieldChange = (position, key, value) => { + if (!position) { + return; + } + setTrackFields((prev) => ({ + ...prev, + [position]: { + ...(prev[position] || {}), + [key]: value + } + })); + }; + + const handleMetaFieldChange = (key, value) => { + setMetaFields((prev) => ({ + ...prev, + [key]: value + })); + }; + const handleStart = () => { - const selected = tracks - .filter((t) => selectedTracks[t.position] !== false) + const albumTitle = normalizeTrackText(metaFields?.title) + || normalizeTrackText(selectedMeta?.title) + || normalizeTrackText(context?.detectedTitle) + || 'Audio CD'; + const albumArtist = normalizeTrackText(metaFields?.artist) + || normalizeTrackText(selectedMeta?.artist) + || null; + const albumYear = normalizeYear(metaFields?.year); + + const normalizedTracks = tracks + .map((t) => { + const position = normalizePosition(t?.position); + if (!position) { + return null; + } + const baseTitle = normalizeTrackText(t?.title) || `Track ${position}`; + const baseArtist = normalizeTrackText(t?.artist) || normalizeTrackText(selectedMeta?.artist); + const edited = trackFields[position] || {}; + const title = normalizeTrackText(edited.title) || baseTitle; + const artist = normalizeTrackText(edited.artist) || baseArtist || null; + return { + position, + title, + artist, + selected: selectedTracks[position] !== false + }; + }) + .filter(Boolean); + + const selected = normalizedTracks + .filter((t) => t.selected) .map((t) => t.position); if (selected.length === 0) { @@ -118,14 +408,74 @@ export default function CdRipConfigPanel({ onStart && onStart({ format, formatOptions, - selectedTracks: selected + selectedTracks: selected, + tracks: normalizedTracks, + metadata: { + title: albumTitle, + artist: albumArtist, + year: albumYear + } }); }; const schema = CD_FORMAT_SCHEMAS[format] || { fields: [] }; const visibleFields = schema.fields.filter((f) => isFieldVisible(f, formatOptions)); - const selectedCount = tracks.filter((t) => selectedTracks[t.position] !== false).length; + const selectedCount = tracks.filter((t) => { + const position = normalizePosition(t?.position); + return position ? selectedTracks[position] !== false : false; + }).length; + const firstSelectedTrack = tracks.find((t) => { + const position = normalizePosition(t?.position); + return position ? selectedTracks[position] !== false : false; + }) || null; + const devicePath = String(context?.devicePath || context?.device?.path || '').trim(); + const cdparanoiaCmd = settingsCdparanoiaCmd + || String(context?.cdparanoiaCmd || '').trim() + || 'cdparanoia'; + const rawWavDir = String(context?.rawWavDir || '').trim(); + const commandTrackNumber = firstSelectedTrack ? Math.trunc(Number(firstSelectedTrack.position)) : null; + const commandWavTarget = commandTrackNumber + ? ( + rawWavDir + ? `${rawWavDir}/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav` + : `/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav` + ) + : '/trackNN.cdda.wav'; + const cdparanoiaCommandPreview = [ + quoteShellArg(cdparanoiaCmd), + '-d', + quoteShellArg(devicePath || ''), + String(commandTrackNumber || ''), + quoteShellArg(commandWavTarget) + ].join(' '); + const commandTrackNo = commandTrackNumber || 1; + const commandTrackFields = trackFields[commandTrackNo] || {}; + const commandTrackTitle = normalizeTrackText(commandTrackFields.title) + || normalizeTrackText(firstSelectedTrack?.title) + || `Track ${commandTrackNo}`; + const commandTrackArtist = normalizeTrackText(commandTrackFields.artist) + || normalizeTrackText(firstSelectedTrack?.artist) + || normalizeTrackText(metaFields?.artist) + || normalizeTrackText(selectedMeta?.artist) + || 'Unknown Artist'; + const commandAlbumTitle = normalizeTrackText(metaFields?.title) + || normalizeTrackText(selectedMeta?.title) + || normalizeTrackText(context?.detectedTitle) + || 'Audio CD'; + const commandYear = normalizeYear(metaFields?.year) ?? normalizeYear(selectedMeta?.year); + const commandOutputFile = `/track${String(commandTrackNo).padStart(2, '0')}.${format}`; + const encodeCommandPreview = buildEncodeCommandPreview({ + format, + formatOptions, + wavFile: commandWavTarget, + outFile: commandOutputFile, + trackTitle: commandTrackTitle, + trackArtist: commandTrackArtist, + albumTitle: commandAlbumTitle, + year: commandYear, + trackNo: commandTrackNo + }); const progress = Number(pipeline?.progress ?? 0); const clampedProgress = Math.max(0, Math.min(100, progress)); const eta = String(pipeline?.eta || '').trim(); @@ -140,6 +490,8 @@ export default function CdRipConfigPanel({ severity={isFinished ? 'success' : 'info'} /> {statusText ? {statusText} : null} + 1) {cdparanoiaCommandPreview} + {encodeCommandPreview ? 2) {encodeCommandPreview} : null} {!isFinished ? ( <> @@ -165,12 +517,32 @@ export default function CdRipConfigPanel({

CD-Rip Konfiguration

- {selectedMeta.title ? ( -
- {selectedMeta.artist ? `${selectedMeta.artist} – ` : ''}{selectedMeta.title} - {selectedMeta.year ? ({selectedMeta.year}) : null} +
+ Album-Metadaten +
+ handleMetaFieldChange('title', e.target.value)} + placeholder="Album" + disabled={busy} + /> + handleMetaFieldChange('artist', e.target.value)} + placeholder="Interpret" + disabled={busy} + /> + handleMetaFieldChange('year', normalizeYear(e.value))} + placeholder="Jahr" + useGrouping={false} + min={1900} + max={2100} + disabled={busy} + />
- ) : null} +
{/* Format selection */}
@@ -210,30 +582,74 @@ export default function CdRipConfigPanel({ />
- {tracks.map((track) => { - const isSelected = selectedTracks[track.position] !== false; - const totalSec = Math.round((track.durationMs || track.durationSec * 1000 || 0) / 1000); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - const duration = totalSec > 0 ? `${min}:${String(sec).padStart(2, '0')}` : '-'; - return ( - - ); - })} + + + + + + + + + + + + {tracks.map((track) => { + const position = normalizePosition(track?.position); + if (!position) { + return null; + } + const isSelected = selectedTracks[position] !== false; + const fields = trackFields[position] || {}; + const titleValue = normalizeTrackText(fields.title) + || normalizeTrackText(track?.title) + || `Track ${position}`; + const artistValue = normalizeTrackText(fields.artist) + || normalizeTrackText(track?.artist) + || normalizeTrackText(selectedMeta?.artist); + const duration = formatTrackDuration(track); + return ( + + + + + + + + ); + })} + +
AuswahlNrInterpretTitelLänge
+ handleToggleTrack(position)} + disabled={busy} + /> + {String(position).padStart(2, '0')} + handleTrackFieldChange(position, 'artist', e.target.value)} + placeholder="Interpret" + disabled={busy || !isSelected} + /> + + handleTrackFieldChange(position, 'title', e.target.value)} + placeholder={`Track ${position}`} + disabled={busy || !isSelected} + /> + {duration}
) : null} +
+ + 1) {cdparanoiaCommandPreview} + {encodeCommandPreview ? 2) {encodeCommandPreview} : null} +
+ {/* Actions */}