diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 75981c0..f98df5e 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -46,6 +46,50 @@ router.get( }) ); +router.get( + '/cd/musicbrainz/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query }); + const results = await pipelineService.searchMusicBrainz(String(query)); + res.json({ results }); + }) +); + +router.post( + '/cd/select-metadata', + asyncHandler(async (req, res) => { + const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body; + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId }); + const job = await pipelineService.selectCdMetadata({ + jobId: Number(jobId), + title, + artist, + year, + mbId, + coverUrl, + tracks + }); + res.json({ job }); + }) +); + +router.post( + '/cd/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const ripConfig = req.body || {}; + logger.info('post:cd:start', { reqId: req.reqId, jobId, format: ripConfig.format }); + const result = await pipelineService.startCdRip(jobId, ripConfig); + res.json({ result }); + }) +); + router.post( '/select-metadata', asyncHandler(async (req, res) => { diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js new file mode 100644 index 0000000..1235854 --- /dev/null +++ b/backend/src/services/cdRipService.js @@ -0,0 +1,353 @@ +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'); +const { ensureDir } = require('../utils/files'); +const { errorToMeta } = require('../utils/errorMeta'); + +const execFileAsync = promisify(execFile); + +const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']); + +/** + * 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) + */ +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) { + continue; + } + const startSector = Number(m[2]); + const lengthSector = Number(m[6]); + // duration in seconds: sectors / 75 + const durationSec = Math.round(lengthSector / 75); + tracks.push({ + position: Number(m[1]), + startSector, + lengthSector, + durationSec, + durationMs: durationSec * 1000 + }); + } + return tracks; +} + +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], { + timeout: 15000 + }); + const tracks = parseToc(stderr); + logger.info('toc:done', { devicePath, trackCount: tracks.length }); + return tracks; + } catch (error) { + // cdparanoia -Q exits non-zero sometimes even on success; try parsing stderr + const stderr = String(error?.stderr || ''); + const tracks = parseToc(stderr); + if (tracks.length > 0) { + logger.info('toc:done-from-stderr', { devicePath, trackCount: tracks.length }); + return tracks; + } + logger.warn('toc:failed', { devicePath, error: errorToMeta(error) }); + return []; + } +} + +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 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); +} + +/** + * Rip and encode a CD. + * + * @param {object} options + * @param {string} options.jobId - Job ID for logging + * @param {string} options.devicePath - e.g. /dev/sr0 + * @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia + * @param {string} options.rawWavDir - temp dir for WAV files + * @param {string} options.outputDir - final output dir + * @param {string} options.format - wav|flac|mp3|opus|ogg + * @param {object} options.formatOptions - encoder-specific options + * @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 {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void + * @param {Function} options.onLog - (level, msg) => void + * @param {object} options.context - passed to spawnTrackedProcess + */ +async function ripAndEncode(options) { + const { + jobId, + devicePath, + cdparanoiaCmd = 'cdparanoia', + rawWavDir, + outputDir, + format = 'flac', + formatOptions = {}, + selectedTracks = [], + tracks = [], + meta = {}, + onProgress, + onLog, + context + } = options; + + if (!SUPPORTED_FORMATS.has(format)) { + throw new Error(`Unbekanntes Ausgabeformat: ${format}`); + } + + const tracksToRip = selectedTracks.length > 0 + ? tracks.filter((t) => selectedTracks.includes(t.position)) + : tracks; + + if (tracksToRip.length === 0) { + throw new Error('Keine Tracks zum Rippen ausgewählt.'); + } + + await ensureDir(rawWavDir); + await ensureDir(outputDir); + + logger.info('rip:start', { + jobId, + devicePath, + format, + trackCount: tracksToRip.length + }); + + const log = (level, msg) => { + logger[level] && logger[level](msg, { jobId }); + onLog && onLog(level, msg); + }; + + // ── Phase 1: Rip each selected track to WAV ────────────────────────────── + for (let i = 0; i < tracksToRip.length; i++) { + const track = tracksToRip[i]; + const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + + log('info', `Rippe Track ${track.position} von ${tracks.length} …`); + + 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) { + throw new Error( + `cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${runInfo.exitCode})` + ); + } + + onProgress && onProgress({ + phase: 'rip', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + percent: ((i + 1) / tracksToRip.length) * 50 + }); + + log('info', `Track ${track.position} gerippt.`); + } + + // ── Phase 2: Encode WAVs to target format ───────────────────────────────── + if (format === 'wav') { + // Just move WAV files to output dir with proper names + for (let i = 0; i < tracksToRip.length; i++) { + 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')); + fs.renameSync(wavFile, outFile); + onProgress && onProgress({ + phase: 'encode', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + percent: 50 + ((i + 1) / tracksToRip.length) * 50 + }); + log('info', `WAV für Track ${track.position} gespeichert.`); + } + return { outputDir, format, trackCount: tracksToRip.length }; + } + + for (let i = 0; i < tracksToRip.length; i++) { + const track = tracksToRip[i]; + const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + + if (!fs.existsSync(wavFile)) { + 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); + + log('info', `Encodiere Track ${track.position} → ${outFilename} …`); + + const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile); + + await spawnTrackedProcess({ + cmd: encodeArgs.cmd, + args: encodeArgs.args, + cwd: rawWavDir, + onStdoutLine() {}, + onStderrLine() {}, + context + }); + + // Clean up WAV after encode + try { + fs.unlinkSync(wavFile); + } catch (_error) { + // ignore cleanup errors + } + + onProgress && onProgress({ + phase: 'encode', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + percent: 50 + ((i + 1) / tracksToRip.length) * 50 + }); + + log('info', `Track ${track.position} encodiert.`); + } + + return { outputDir, format, trackCount: tracksToRip.length }; +} + +function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) { + const artist = meta?.artist || ''; + const album = meta?.title || ''; + const year = meta?.year ? String(meta.year) : ''; + const trackTitle = track.title || `Track ${track.position}`; + const trackNum = String(track.position); + + if (format === 'flac') { + const level = Number(opts.flacCompression ?? 5); + const clampedLevel = Math.max(0, Math.min(8, level)); + return { + cmd: 'flac', + args: [ + `--compression-level-${clampedLevel}`, + '--tag', `TITLE=${trackTitle}`, + '--tag', `ARTIST=${artist}`, + '--tag', `ALBUM=${album}`, + '--tag', `DATE=${year}`, + '--tag', `TRACKNUMBER=${trackNum}`, + wavFile, + '-o', outFile + ] + }; + } + + if (format === 'mp3') { + const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase(); + const args = ['--id3v2-only', '--noreplaygain']; + if (mode === 'vbr') { + const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4))); + args.push('-V', String(quality)); + } else { + const bitrate = Number(opts.mp3Bitrate ?? 192); + args.push('-b', String(bitrate)); + } + args.push( + '--tt', trackTitle, + '--ta', artist, + '--tl', album, + '--ty', year, + '--tn', trackNum, + wavFile, + outFile + ); + return { cmd: 'lame', args }; + } + + if (format === 'opus') { + const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160))); + const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10))); + return { + cmd: 'opusenc', + args: [ + '--bitrate', String(bitrate), + '--comp', String(complexity), + '--title', trackTitle, + '--artist', artist, + '--album', album, + '--date', year, + '--tracknumber', trackNum, + wavFile, + outFile + ] + }; + } + + if (format === 'ogg') { + const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6))); + return { + cmd: 'oggenc', + args: [ + '-q', String(quality), + '-t', trackTitle, + '-a', artist, + '-l', album, + '-d', year, + '-N', trackNum, + '-o', outFile, + wavFile + ] + }; + } + + throw new Error(`Unbekanntes Format: ${format}`); +} + +module.exports = { + readToc, + ripAndEncode, + buildOutputDir, + SUPPORTED_FORMATS +}; diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index b8b4bf3..b4a142d 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -52,14 +52,17 @@ function normalizeMediaProfile(rawValue) { ) { return 'dvd'; } - if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') { + if (value === 'cd' || value === 'audio_cd') { + return 'cd'; + } + if (value === 'disc' || value === 'other' || value === 'sonstiges') { return 'other'; } return null; } function isSpecificMediaProfile(value) { - return value === 'bluray' || value === 'dvd'; + return value === 'bluray' || value === 'dvd' || value === 'cd'; } function inferMediaProfileFromTextParts(parts) { @@ -82,6 +85,9 @@ function inferMediaProfileFromTextParts(parts) { function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { const fstype = String(rawFsType || '').trim().toLowerCase(); + if (fstype === 'audio_cd') { + return 'cd'; + } const model = String(rawModel || '').trim().toLowerCase(); const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); const hasDvdModelMarker = /dvd/.test(model); @@ -142,7 +148,7 @@ function inferMediaProfileFromUdevProperties(properties = {}) { return 'dvd'; } if (hasFlag('ID_CDROM_MEDIA_CD')) { - return 'other'; + return 'cd'; } return null; } @@ -493,22 +499,48 @@ class DiskDetectionService extends EventEmitter { } async checkMediaPresent(devicePath) { + let blkidType = null; try { const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); - const type = String(stdout || '').trim().toLowerCase(); - const has = type.length > 0; - logger.debug('blkid:result', { devicePath, hasMedia: has, type }); - return { - hasMedia: has, - type: type || null - }; - } catch (error) { - logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) }); - return { - hasMedia: false, - type: null - }; + blkidType = String(stdout || '').trim().toLowerCase() || null; + } catch (_error) { + // blkid failed – could mean no disc, or an audio CD (no filesystem type) } + + if (blkidType) { + logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType }); + return { hasMedia: true, type: blkidType }; + } + + // blkid found nothing – audio CDs have no filesystem, so fall back to udevadm + try { + const { stdout } = await execFileAsync('udevadm', [ + 'info', + '--query=property', + '--name', + devicePath + ]); + const props = {}; + for (const line of String(stdout || '').split(/\r?\n/)) { + const idx = line.indexOf('='); + if (idx <= 0) { + continue; + } + props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim(); + } + const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1'); + const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1'); + const hasCD = props['ID_CDROM_MEDIA_CD'] === '1'; + if (hasCD && !hasDVD && !hasBD) { + logger.debug('udevadm:audio-cd', { devicePath }); + return { hasMedia: true, type: 'audio_cd' }; + } + } catch (_udevError) { + // udevadm not available or failed – ignore + } + + logger.debug('blkid:no-media-or-fail', { devicePath }); + return { hasMedia: false, type: null }; } async getDiscLabel(devicePath) { @@ -560,6 +592,11 @@ class DiskDetectionService extends EventEmitter { } async inferMediaProfile(devicePath, hints = {}) { + // Audio CDs have no filesystem – short-circuit immediately + if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') { + return 'cd'; + } + const explicit = normalizeMediaProfile(hints?.mediaProfile); if (isSpecificMediaProfile(explicit)) { return explicit; diff --git a/backend/src/services/musicBrainzService.js b/backend/src/services/musicBrainzService.js new file mode 100644 index 0000000..3eb8c1b --- /dev/null +++ b/backend/src/services/musicBrainzService.js @@ -0,0 +1,142 @@ +const settingsService = require('./settingsService'); +const logger = require('./logger').child('MUSICBRAINZ'); + +const MB_BASE = 'https://musicbrainz.org/ws/2'; +const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)'; +const MB_TIMEOUT_MS = 10000; + +async function mbFetch(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': MB_USER_AGENT + }, + signal: controller.signal + }); + clearTimeout(timer); + if (!response.ok) { + throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`); + } + return response.json(); + } catch (error) { + clearTimeout(timer); + throw error; + } +} + +function normalizeRelease(release) { + if (!release) { + return null; + } + const artistCredit = Array.isArray(release['artist-credit']) + ? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ') + : null; + const date = String(release.date || '').trim(); + const yearMatch = date.match(/\b(\d{4})\b/); + const year = yearMatch ? Number(yearMatch[1]) : null; + + const media = Array.isArray(release.media) ? release.media : []; + const tracks = media.flatMap((medium, mediumIdx) => { + const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : []; + return mediumTracks.map((track) => ({ + position: Number(track.position || mediumIdx * 100 + 1), + number: String(track.number || track.position || ''), + title: String(track.title || ''), + durationMs: Number(track.length || 0) || null + })); + }); + + let coverArtUrl = null; + if (release['cover-art-archive'] && release['cover-art-archive'].front) { + coverArtUrl = `https://coverartarchive.org/release/${release.id}/front-250`; + } + + return { + mbId: String(release.id || ''), + title: String(release.title || ''), + artist: artistCredit || null, + year, + date, + country: String(release.country || '').trim() || null, + label: Array.isArray(release['label-info']) + ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null + : null, + coverArtUrl, + tracks + }; +} + +class MusicBrainzService { + async isEnabled() { + const settings = await settingsService.getSettingsMap(); + return settings.musicbrainz_enabled !== 'false'; + } + + async searchByTitle(query) { + const q = String(query || '').trim(); + if (!q) { + return []; + } + + const enabled = await this.isEnabled(); + if (!enabled) { + return []; + } + + logger.info('search:start', { query: q }); + + const url = new URL(`${MB_BASE}/release`); + url.searchParams.set('query', q); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('limit', '10'); + url.searchParams.set('inc', 'artist-credits+labels+recordings'); + + try { + const data = await mbFetch(url.toString()); + const releases = Array.isArray(data.releases) ? data.releases : []; + const results = releases.map(normalizeRelease).filter(Boolean); + logger.info('search:done', { query: q, count: results.length }); + return results; + } catch (error) { + logger.warn('search:failed', { query: q, error: String(error?.message || error) }); + return []; + } + } + + async searchByDiscLabel(discLabel) { + return this.searchByTitle(discLabel); + } + + async getReleaseById(mbId) { + const id = String(mbId || '').trim(); + if (!id) { + return null; + } + + const enabled = await this.isEnabled(); + if (!enabled) { + return null; + } + + logger.info('getById:start', { mbId: id }); + + const url = new URL(`${MB_BASE}/release/${id}`); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+cover-art-archive'); + + try { + const data = await mbFetch(url.toString()); + const result = normalizeRelease(data); + logger.info('getById:done', { mbId: id, title: result?.title }); + return result; + } catch (error) { + logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) }); + return null; + } + } +} + +module.exports = new MusicBrainzService(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 6f186bf..d02d7da 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -5,6 +5,8 @@ const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); +const musicBrainzService = require('./musicBrainzService'); +const cdRipService = require('./cdRipService'); const scriptService = require('./scriptService'); const scriptChainService = require('./scriptChainService'); const runtimeActivityService = require('./runtimeActivityService'); @@ -20,7 +22,7 @@ const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/pl const { errorToMeta } = require('../utils/errorMeta'); const userPresetService = require('./userPresetService'); -const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']); +const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); const REVIEW_REFRESH_SETTING_PREFIXES = [ 'handbrake_', 'mediainfo_', @@ -101,14 +103,17 @@ function normalizeMediaProfile(value) { ) { return 'dvd'; } - if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { + if (raw === 'cd' || raw === 'audio_cd') { + return 'cd'; + } + if (raw === 'disc' || raw === 'other' || raw === 'sonstiges') { return 'other'; } return null; } function isSpecificMediaProfile(value) { - return value === 'bluray' || value === 'dvd'; + return value === 'bluray' || value === 'dvd' || value === 'cd'; } function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { @@ -5114,6 +5119,11 @@ class PipelineService extends EventEmitter { mediaProfile }; + // Route audio CDs to the dedicated CD pipeline + if (mediaProfile === 'cd') { + return this.analyzeCd(deviceWithProfile); + } + const job = await historyService.createJob({ discDevice: device.path, status: 'METADATA_SELECTION', @@ -9752,6 +9762,347 @@ class PipelineService extends EventEmitter { }); } + // ── CD Pipeline ───────────────────────────────────────────────────────────── + + async analyzeCd(device) { + const devicePath = String(device?.path || '').trim(); + const detectedTitle = String( + device?.discLabel || device?.label || device?.model || 'Audio CD' + ).trim(); + + logger.info('cd:analyze:start', { devicePath, detectedTitle }); + + const job = await historyService.createJob({ + discDevice: devicePath, + status: 'CD_METADATA_SELECTION', + detectedTitle + }); + + try { + const settings = await settingsService.getSettingsMap(); + const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + + // Read TOC + await this.setState('CD_ANALYZING', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'CD wird analysiert …', + context: { jobId: job.id, device, mediaProfile: 'cd' } + }); + + const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); + logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length }); + + // Search MusicBrainz + const mbCandidates = await musicBrainzService + .searchByDiscLabel(detectedTitle) + .catch(() => []); + + const cdInfo = { + phase: 'PREPARE', + mediaProfile: 'cd', + preparedAt: nowIso(), + tracks, + detectedTitle + }; + + await historyService.updateJob(job.id, { + status: 'CD_METADATA_SELECTION', + last_state: 'CD_METADATA_SELECTION', + detected_title: detectedTitle, + makemkv_info_json: JSON.stringify(cdInfo) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `CD analysiert: ${tracks.length} Track(s) gefunden. MusicBrainz: ${mbCandidates.length} Treffer.` + ); + + const runningJobs = await historyService.getRunningJobs(); + const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id)); + if (!foreignRunningJobs.length) { + 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', + detectedTitle, + tracks, + mbCandidates + } + }); + } + + return { jobId: job.id, detectedTitle, tracks, mbCandidates }; + } catch (error) { + logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) }); + await this.failJob(job.id, 'CD_ANALYZING', error); + throw error; + } + } + + async searchMusicBrainz(query) { + logger.info('musicbrainz:search', { query }); + const results = await musicBrainzService.searchByTitle(query); + logger.info('musicbrainz:search:done', { query, count: results.length }); + return results; + } + + async selectCdMetadata(payload) { + const { + jobId, + title, + artist, + year, + mbId, + coverUrl, + tracks: selectedTracks + } = payload || {}; + + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + logger.info('cd:select-metadata', { jobId, title, artist, year, mbId }); + + const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + + // Merge track metadata from selection into existing TOC tracks + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const mergedTracks = tocTracks.map((t) => { + const selected = Array.isArray(selectedTracks) + ? selectedTracks.find((st) => Number(st.position) === Number(t.position)) + : null; + return { + ...t, + title: selected?.title || t.title || `Track ${t.position}`, + selected: selected ? Boolean(selected.selected) : true + }; + }); + + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl } + }; + + await historyService.updateJob(jobId, { + title: title || null, + year: year ? Number(year) : null, + poster_url: coverUrl || null, + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + makemkv_info_json: JSON.stringify(updatedCdInfo) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` + ); + + if (this.isPrimaryJob(jobId)) { + await this.setState('CD_READY_TO_RIP', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'CD bereit zum Rippen', + context: { + ...(this.snapshot.context || {}), + jobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl } + } + }); + } + + return historyService.getJobById(jobId); + } + + async startCdRip(jobId, ripConfig) { + this.ensureNotBusy('startCdRip', jobId); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const device = this.detectedDisc || this.snapshot.context?.device; + const devicePath = String(device?.path || job.disc_device || '').trim(); + + if (!devicePath) { + const error = new Error('Kein CD-Laufwerk bekannt.'); + error.statusCode = 400; + throw error; + } + + const format = String(ripConfig?.format || 'flac').trim().toLowerCase(); + const formatOptions = ripConfig?.formatOptions || {}; + const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks) + ? ripConfig.selectedTracks.map(Number).filter(Number.isFinite) + : []; + + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const selectedMeta = cdInfo.selectedMetadata || {}; + + 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 jobDir = `CD_Job${jobId}_${Date.now()}`; + const rawWavDir = path.join(rawBaseDir, jobDir, 'wav'); + const outputDir = cdRipService.buildOutputDir(selectedMeta, path.join(rawBaseDir, jobDir)); + + await historyService.updateJob(jobId, { + status: 'CD_RIPPING', + last_state: 'CD_RIPPING', + error_message: null, + raw_path: rawWavDir, + output_path: outputDir, + encode_plan_json: JSON.stringify({ format, formatOptions, selectedTracks: selectedTrackPositions }) + }); + + await this.setState('CD_RIPPING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'CD wird gerippt …', + context: { + ...(this.snapshot.context || {}), + jobId, + mediaProfile: 'cd', + selectedMetadata: selectedMeta + } + }); + + 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'}`); + + // Run asynchronously so the HTTP response returns immediately + this._runCdRip({ + job, + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + selectedTrackPositions, + tocTracks, + selectedMeta + }).catch((error) => { + logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) }); + }); + + return { jobId, started: true }; + } + + async _runCdRip({ + job, + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + selectedTrackPositions, + tocTracks, + selectedMeta + }) { + const processKey = Number(jobId); + this.activeProcesses.set(processKey, { cancel: () => {} }); + + try { + await cdRipService.ripAndEncode({ + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + 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'; + + if (phase === 'encode' && this.snapshot.state === 'CD_RIPPING') { + 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 + }); + }, + onLog: async (level, msg) => { + await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); + }, + context: { jobId: processKey } + }); + + // Success + await historyService.updateJob(jobId, { + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + output_path: outputDir + }); + await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); + + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: 'CD-Rip abgeschlossen', + context: { + jobId, + mediaProfile: 'cd', + outputDir, + selectedMetadata: selectedMeta + } + }); + + void this.notifyPushover('job_finished', { + title: 'Ripster - CD Rip erfolgreich', + message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` + }); + } catch (error) { + logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); + await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error); + } finally { + this.activeProcesses.delete(processKey); + } + } + } module.exports = new PipelineService(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 91b9869..b19b5ca 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -36,17 +36,19 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']); const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']); const LOG_DIR_SETTING_KEY = 'log_dir'; -const MEDIA_PROFILES = ['bluray', 'dvd', 'other']; +const MEDIA_PROFILES = ['bluray', 'dvd', 'other', 'cd']; const PROFILED_SETTINGS = { raw_dir: { bluray: 'raw_dir_bluray', dvd: 'raw_dir_dvd', - other: 'raw_dir_other' + other: 'raw_dir_other', + cd: 'raw_dir_cd' }, raw_dir_owner: { bluray: 'raw_dir_bluray_owner', dvd: 'raw_dir_dvd_owner', - other: 'raw_dir_other_owner' + other: 'raw_dir_other_owner', + cd: 'raw_dir_cd_owner' }, movie_dir: { bluray: 'movie_dir_bluray', diff --git a/backend/src/utils/progressParsers.js b/backend/src/utils/progressParsers.js index 110aca5..3766250 100644 --- a/backend/src/utils/progressParsers.js +++ b/backend/src/utils/progressParsers.js @@ -63,7 +63,38 @@ function parseHandBrakeProgress(line) { return null; } +function parseCdParanoiaProgress(line) { + // cdparanoia writes progress to stderr with \r overwrites. + // Formats seen in the wild: + // "Ripping track 1 of 12 progress: ( 34.21%)" + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + + const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i); + if (progressMatch) { + const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i); + const currentTrack = trackMatch ? Number(trackMatch[1]) : null; + const totalTracks = trackMatch ? Number(trackMatch[2]) : null; + return { + percent: clampPercent(Number(progressMatch[1])), + currentTrack, + totalTracks, + eta: null + }; + } + + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style – no clear percent here + // Fall back to generic percent match + const percent = parseGenericPercent(normalized); + if (percent !== null) { + return { percent, currentTrack: null, totalTracks: null, eta: null }; + } + + return null; +} + module.exports = { parseMakeMkvProgress, - parseHandBrakeProgress + parseHandBrakeProgress, + parseCdParanoiaProgress }; diff --git a/db/schema.sql b/db/schema.sql index 594a09f..c0da2f2 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -340,6 +340,20 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL); +-- Tools – CD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +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'); + +-- 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); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', '/opt/ripster/backend/data/output/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_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); + -- Metadaten INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); @@ -349,6 +363,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true'); + -- Benachrichtigungen INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 16d5dd9..10df1fa 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -273,6 +273,25 @@ export const api = { searchOmdb(q) { return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); }, + searchMusicBrainz(q) { + return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); + }, + async selectCdMetadata(payload) { + const result = await request('/pipeline/cd/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startCdRip(jobId, ripConfig) { + const result = await request(`/pipeline/cd/start/${jobId}`, { + method: 'POST', + body: JSON.stringify(ripConfig || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, async selectMetadata(payload) { const result = await request('/pipeline/select-metadata', { method: 'POST', diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx new file mode 100644 index 0000000..4ab387d --- /dev/null +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -0,0 +1,282 @@ +import { useEffect, 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 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')}`; +} + +export default function CdMetadataDialog({ + visible, + context, + onHide, + onSubmit, + onSearch, + busy +}) { + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(''); + const [extraResults, setExtraResults] = useState([]); + + // Manual metadata inputs + const [manualTitle, setManualTitle] = useState(''); + const [manualArtist, setManualArtist] = useState(''); + const [manualYear, setManualYear] = useState(null); + + // Per-track title editing + const [trackTitles, setTrackTitles] = useState({}); + const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set()); + + const tocTracks = Array.isArray(context?.tracks) ? context.tracks : []; + + useEffect(() => { + if (!visible) { + return; + } + setSelected(null); + setQuery(context?.detectedTitle || ''); + setManualTitle(context?.detectedTitle || ''); + setManualArtist(''); + setManualYear(null); + setExtraResults([]); + + 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(() => { + if (!selected) { + return; + } + setManualTitle(selected.title || ''); + setManualArtist(selected.artist || ''); + setManualYear(selected.year || null); + + // Pre-fill track titles from the MusicBrainz result + if (Array.isArray(selected.tracks) && selected.tracks.length > 0) { + const titles = {}; + for (const t of selected.tracks) { + if (t.position <= tocTracks.length) { + titles[t.position] = t.title || `Track ${t.position}`; + } + } + // Fill any remaining tracks not in MB result + for (const t of tocTracks) { + if (!titles[t.position]) { + titles[t.position] = t.title || `Track ${t.position}`; + } + } + setTrackTitles(titles); + } + }, [selected]); + + const allMbRows = [ + ...(Array.isArray(context?.mbCandidates) ? context.mbCandidates : []), + ...extraResults + ].filter(Boolean); + + // Deduplicate by mbId + const mbRows = []; + const seen = new Set(); + for (const r of allMbRows) { + if (r.mbId && !seen.has(r.mbId)) { + seen.add(r.mbId); + mbRows.push(r); + } + } + + const handleSearch = async () => { + if (!query.trim()) { + return; + } + const results = await onSearch(query.trim()); + setExtraResults(results || []); + }; + + const handleToggleTrack = (position) => { + setSelectedTrackPositions((prev) => { + const next = new Set(prev); + if (next.has(position)) { + next.delete(position); + } else { + next.add(position); + } + 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 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, + tracks + }; + + await onSubmit(payload); + }; + + const mbTitleBody = (row) => ( +
+ {row.coverArtUrl ? ( + {row.title} + ) : ( +
-
+ )} +
+
{row.title}
+ {row.artist}{row.year ? ` | ${row.year}` : ''} + {row.label ? | {row.label} : null} +
+
+ ); + + const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length; + const noneSelected = selectedTrackPositions.size === 0; + + return ( + + {/* MusicBrainz search */} +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Album / Interpret suchen" + /> +
+ + {mbRows.length > 0 ? ( +
+ setSelected(e.value)} + dataKey="mbId" + size="small" + scrollable + scrollHeight="16rem" + emptyMessage="Keine Treffer" + > + + + + +
+ ) : null} + + {/* Manual metadata */} +

Metadaten

+
+ setManualTitle(e.target.value)} + placeholder="Album-Titel" + /> + setManualArtist(e.target.value)} + placeholder="Interpret / Band" + /> + setManualYear(e.value)} + placeholder="Jahr" + useGrouping={false} + min={1900} + max={2100} + /> +
+ + {/* Track selection */} + {tocTracks.length > 0 ? ( + <> +
+

Tracks ({tocTracks.length})

+
+
+ {tocTracks.map((track) => ( +
+ handleToggleTrack(track.position)} + inputId={`track-${track.position}`} + /> + {String(track.position).padStart(2, '0')} + setTrackTitles((prev) => ({ ...prev, [track.position]: e.target.value }))} + className="cd-track-title-input" + placeholder={`Track ${track.position}`} + disabled={!selectedTrackPositions.has(track.position)} + /> + + {track.durationMs ? formatDurationMs(track.durationMs) : '-'} + +
+ ))} +
+ + ) : null} + +
+
+
+ ); +} diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx new file mode 100644 index 0000000..cb01219 --- /dev/null +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -0,0 +1,256 @@ +import { useState, useEffect } from 'react'; +import { Dropdown } from 'primereact/dropdown'; +import { Slider } from 'primereact/slider'; +import { Button } from 'primereact/button'; +import { ProgressBar } from 'primereact/progressbar'; +import { Tag } from 'primereact/tag'; +import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; + +function isFieldVisible(field, values) { + if (!field.showWhen) { + return true; + } + return values[field.showWhen.field] === field.showWhen.value; +} + +function FormatField({ field, value, onChange }) { + if (field.type === 'slider') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + min={field.min} + max={field.max} + step={field.step || 1} + /> +
+ ); + } + + if (field.type === 'select') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + /> +
+ ); + } + + return null; +} + +export default function CdRipConfigPanel({ + pipeline, + onStart, + onCancel, + busy +}) { + const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; + const tracks = Array.isArray(context.tracks) ? context.tracks : []; + const selectedMeta = context.selectedMetadata || {}; + const state = String(pipeline?.state || '').trim().toUpperCase(); + + const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING'; + const isFinished = state === 'FINISHED'; + + const [format, setFormat] = useState('flac'); + const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac')); + + // Track selection: position → boolean + const [selectedTracks, setSelectedTracks] = useState(() => { + const map = {}; + for (const t of tracks) { + map[t.position] = true; + } + return map; + }); + + useEffect(() => { + setFormatOptions(getDefaultFormatOptions(format)); + }, [format]); + + useEffect(() => { + const map = {}; + for (const t of tracks) { + map[t.position] = selectedTracks[t.position] !== false; + } + setSelectedTracks(map); + }, [tracks.length]); + + const handleFormatOptionChange = (key, value) => { + setFormatOptions((prev) => ({ ...prev, [key]: value })); + }; + + const handleToggleTrack = (position) => { + setSelectedTracks((prev) => ({ ...prev, [position]: !prev[position] })); + }; + + const handleToggleAll = () => { + const allSelected = tracks.every((t) => selectedTracks[t.position] !== false); + const map = {}; + for (const t of tracks) { + map[t.position] = !allSelected; + } + setSelectedTracks(map); + }; + + const handleStart = () => { + const selected = tracks + .filter((t) => selectedTracks[t.position] !== false) + .map((t) => t.position); + + if (selected.length === 0) { + return; + } + + onStart && onStart({ + format, + formatOptions, + selectedTracks: selected + }); + }; + + 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 progress = Number(pipeline?.progress ?? 0); + const clampedProgress = Math.max(0, Math.min(100, progress)); + const eta = String(pipeline?.eta || '').trim(); + const statusText = String(pipeline?.statusText || '').trim(); + + if (isRipping || isFinished) { + return ( +
+
+ + {statusText ? {statusText} : null} + {!isFinished ? ( + <> + + {Math.round(clampedProgress)}%{eta ? ` | ETA ${eta}` : ''} + + ) : null} +
+ {!isFinished ? ( +
+ ); + } + + return ( +
+

CD-Rip Konfiguration

+ + {selectedMeta.title ? ( +
+ {selectedMeta.artist ? `${selectedMeta.artist} – ` : ''}{selectedMeta.title} + {selectedMeta.year ? ({selectedMeta.year}) : null} +
+ ) : null} + + {/* Format selection */} +
+ + setFormat(e.value)} + disabled={busy} + /> +
+ + {/* Format-specific options */} + {visibleFields.map((field) => ( + + ))} + + {/* Track selection */} + {tracks.length > 0 ? ( +
+
+ Tracks ({selectedCount} / {tracks.length} ausgewählt) +
+
+ {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 ( + + ); + })} +
+
+ ) : null} + + {/* Actions */} +
+
+
+ ); +} diff --git a/frontend/src/config/cdFormatSchemas.js b/frontend/src/config/cdFormatSchemas.js new file mode 100644 index 0000000..c0e3e7d --- /dev/null +++ b/frontend/src/config/cdFormatSchemas.js @@ -0,0 +1,126 @@ +/** + * CD output format schemas. + * Each format defines the fields shown in CdRipConfigPanel. + */ +export const CD_FORMATS = [ + { label: 'FLAC (verlustlos)', value: 'flac' }, + { label: 'MP3', value: 'mp3' }, + { label: 'Opus', value: 'opus' }, + { label: 'OGG Vorbis', value: 'ogg' }, + { label: 'WAV (unkomprimiert)', value: 'wav' } +]; + +export const CD_FORMAT_SCHEMAS = { + wav: { + fields: [] + }, + + flac: { + fields: [ + { + key: 'flacCompression', + label: 'Kompressionsstufe', + description: '0 = schnell / wenig Kompression, 8 = maximale Kompression', + type: 'slider', + min: 0, + max: 8, + step: 1, + default: 5 + } + ] + }, + + mp3: { + fields: [ + { + key: 'mp3Mode', + label: 'Modus', + type: 'select', + options: [ + { label: 'CBR (Konstante Bitrate)', value: 'cbr' }, + { label: 'VBR (Variable Bitrate)', value: 'vbr' } + ], + default: 'cbr' + }, + { + key: 'mp3Bitrate', + label: 'Bitrate (kbps)', + type: 'select', + showWhen: { field: 'mp3Mode', value: 'cbr' }, + options: [ + { label: '128 kbps', value: 128 }, + { label: '160 kbps', value: 160 }, + { label: '192 kbps', value: 192 }, + { label: '256 kbps', value: 256 }, + { label: '320 kbps', value: 320 } + ], + default: 192 + }, + { + key: 'mp3Quality', + label: 'VBR Qualität (V0–V9)', + description: '0 = beste Qualität, 9 = kleinste Datei', + type: 'slider', + min: 0, + max: 9, + step: 1, + showWhen: { field: 'mp3Mode', value: 'vbr' }, + default: 4 + } + ] + }, + + opus: { + fields: [ + { + key: 'opusBitrate', + label: 'Bitrate (kbps)', + description: 'Empfohlen: 96–192 kbps für Musik', + type: 'slider', + min: 32, + max: 512, + step: 8, + default: 160 + }, + { + key: 'opusComplexity', + label: 'Encoder-Komplexität', + description: '0 = schnell, 10 = beste Qualität', + type: 'slider', + min: 0, + max: 10, + step: 1, + default: 10 + } + ] + }, + + ogg: { + fields: [ + { + key: 'oggQuality', + label: 'Qualität', + description: '-1 = kleinste Datei, 10 = beste Qualität. Empfohlen: 5–7.', + type: 'slider', + min: -1, + max: 10, + step: 1, + default: 6 + } + ] + } +}; + +export function getDefaultFormatOptions(format) { + const schema = CD_FORMAT_SCHEMAS[format]; + if (!schema) { + return {}; + } + const defaults = {}; + for (const field of schema.fields) { + if (field.default !== undefined) { + defaults[field.key] = field.default; + } + } + return defaults; +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 937a37d..017b321 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -9,12 +9,14 @@ import { InputNumber } from 'primereact/inputnumber'; import { api } from '../api/client'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; +import CdMetadataDialog from '../components/CdMetadataDialog'; +import CdRipConfigPanel from '../components/CdRipConfigPanel'; 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 } from '../utils/statusPresentation'; -const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING']; +const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']; const dashboardStatuses = new Set([ 'ANALYZING', 'METADATA_SELECTION', @@ -25,7 +27,12 @@ const dashboardStatuses = new Set([ 'RIPPING', 'ENCODING', 'CANCELLED', - 'ERROR' + 'ERROR', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP', + 'CD_ANALYZING', + 'CD_RIPPING', + 'CD_ENCODING' ]); function normalizeJobId(value) { @@ -362,32 +369,25 @@ function resolveMediaType(job) { if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { return 'dvd'; } + if (['cd', 'audio_cd', 'audio cd'].includes(raw)) { + return 'cd'; + } } return 'other'; } function mediaIndicatorMeta(job) { const mediaType = resolveMediaType(job); - return mediaType === 'bluray' - ? { - mediaType, - src: blurayIndicatorIcon, - alt: 'Blu-ray', - title: 'Blu-ray' - } - : mediaType === 'dvd' - ? { - mediaType, - src: discIndicatorIcon, - alt: 'DVD', - title: 'DVD' - } - : { - mediaType, - src: otherIndicatorIcon, - alt: 'Sonstiges Medium', - title: 'Sonstiges Medium' - }; + if (mediaType === 'bluray') { + return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' }; + } + if (mediaType === 'dvd') { + return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' }; + } + if (mediaType === 'cd') { + return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' }; + } + return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' }; } function JobStepChecks({ backupSuccess, encodeSuccess }) { @@ -547,6 +547,9 @@ export default function DashboardPage({ }; const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogContext, setMetadataDialogContext] = useState(null); + const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); + const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null); + const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null); const [cancelCleanupDialog, setCancelCleanupDialog] = useState({ visible: false, jobId: null, @@ -664,6 +667,24 @@ export default function DashboardPage({ } }, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]); + // Auto-open CD metadata dialog when pipeline 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) { + setCdMetadataDialogContext(ctx); + setCdMetadataDialogVisible(true); + } + } + if (currentState === 'CD_READY_TO_RIP') { + const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null; + if (ctx?.jobId) { + setCdRipPanelJobId(ctx.jobId); + } + } + }, [pipeline?.state, pipeline?.context?.jobId]); + useEffect(() => { setQueueState(normalizeQueue(pipeline?.queue)); }, [pipeline?.queue]); @@ -1322,6 +1343,47 @@ export default function DashboardPage({ } }; + const handleMusicBrainzSearch = async (query) => { + try { + const response = await api.searchMusicBrainz(query); + return response.results || []; + } catch (error) { + showError(error); + return []; + } + }; + + const handleCdMetadataSubmit = async (payload) => { + setBusy(true); + try { + await api.selectCdMetadata(payload); + await refreshPipeline(); + await loadDashboardJobs(); + setCdMetadataDialogVisible(false); + setCdMetadataDialogContext(null); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleCdRipStart = async (jobId, ripConfig) => { + if (!jobId) { + return; + } + setJobBusy(jobId, true); + try { + await api.startCdRip(jobId, ripConfig); + await refreshPipeline(); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setJobBusy(jobId, false); + } + }; + const device = lastDiscEvent || pipeline?.context?.device; const canReanalyze = state === 'ENCODING' ? Boolean(device) @@ -2034,6 +2096,40 @@ export default function DashboardPage({ disabled={busyJobIds.has(jobId)} /> + {(() => { + const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase(); + const isCdJob = jobState.startsWith('CD_'); + if (isCdJob) { + return ( + <> + {jobState === 'CD_METADATA_SELECTION' ? ( +