First test

This commit is contained in:
2026-03-12 08:00:23 +00:00
parent 190f6fe1a5
commit c13ce5a50b
15 changed files with 1842 additions and 46 deletions

View File

@@ -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) => {

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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',

View File

@@ -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
};

View File

@@ -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);

View File

@@ -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',

View File

@@ -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) => (
<div className="mb-result-row">
{row.coverArtUrl ? (
<img src={row.coverArtUrl} alt={row.title} className="poster-thumb-lg" />
) : (
<div className="poster-thumb-lg poster-fallback">-</div>
)}
<div>
<div><strong>{row.title}</strong></div>
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
{row.label ? <small> | {row.label}</small> : null}
</div>
</div>
);
const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length;
const noneSelected = selectedTrackPositions.size === 0;
return (
<Dialog
header="CD-Metadaten auswählen"
visible={visible}
onHide={onHide}
style={{ width: '58rem', maxWidth: '97vw' }}
className="cd-metadata-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
{/* MusicBrainz search */}
<div className="search-row">
<InputText
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Album / Interpret suchen"
/>
<Button label="MusicBrainz Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
</div>
{mbRows.length > 0 ? (
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={mbRows}
selectionMode="single"
selection={selected}
onSelectionChange={(e) => setSelected(e.value)}
dataKey="mbId"
size="small"
scrollable
scrollHeight="16rem"
emptyMessage="Keine Treffer"
>
<Column header="Album" body={mbTitleBody} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable>
</div>
) : null}
{/* Manual metadata */}
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
<div className="metadata-grid">
<InputText
value={manualTitle}
onChange={(e) => setManualTitle(e.target.value)}
placeholder="Album-Titel"
/>
<InputText
value={manualArtist}
onChange={(e) => setManualArtist(e.target.value)}
placeholder="Interpret / Band"
/>
<InputNumber
value={manualYear}
onValueChange={(e) => setManualYear(e.value)}
placeholder="Jahr"
useGrouping={false}
min={1900}
max={2100}
/>
</div>
{/* Track selection */}
{tocTracks.length > 0 ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '1rem', marginBottom: '0.25rem' }}>
<h4 style={{ margin: 0 }}>Tracks ({tocTracks.length})</h4>
<Button
label={allSelected ? 'Alle abwählen' : 'Alle auswählen'}
size="small"
severity="secondary"
outlined
onClick={handleToggleAll}
/>
</div>
<div className="cd-track-list">
{tocTracks.map((track) => (
<div key={track.position} className="cd-track-row">
<Checkbox
checked={selectedTrackPositions.has(track.position)}
onChange={() => handleToggleTrack(track.position)}
inputId={`track-${track.position}`}
/>
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
<InputText
value={trackTitles[track.position] ?? `Track ${track.position}`}
onChange={(e) => setTrackTitles((prev) => ({ ...prev, [track.position]: e.target.value }))}
className="cd-track-title-input"
placeholder={`Track ${track.position}`}
disabled={!selectedTrackPositions.has(track.position)}
/>
<span className="cd-track-duration">
{track.durationMs ? formatDurationMs(track.durationMs) : '-'}
</span>
</div>
))}
</div>
</>
) : null}
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
<Button
label="Weiter"
icon="pi pi-arrow-right"
onClick={handleSubmit}
loading={busy}
disabled={noneSelected || (!manualTitle.trim() && !context?.detectedTitle)}
/>
</div>
</Dialog>
);
}

View File

@@ -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 (
<div className="cd-format-field">
<label>
{field.label}: <strong>{value}</strong>
</label>
{field.description ? <small>{field.description}</small> : null}
<Slider
value={value}
onChange={(e) => onChange(field.key, e.value)}
min={field.min}
max={field.max}
step={field.step || 1}
/>
</div>
);
}
if (field.type === 'select') {
return (
<div className="cd-format-field">
<label>{field.label}</label>
{field.description ? <small>{field.description}</small> : null}
<Dropdown
value={value}
options={field.options}
optionLabel="label"
optionValue="value"
onChange={(e) => onChange(field.key, e.value)}
/>
</div>
);
}
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 (
<div className="cd-rip-config-panel">
<div className="cd-rip-status">
<Tag
value={state === 'CD_RIPPING' ? 'Ripping' : state === 'CD_ENCODING' ? 'Encodierung' : 'Fertig'}
severity={isFinished ? 'success' : 'info'}
/>
{statusText ? <small>{statusText}</small> : null}
{!isFinished ? (
<>
<ProgressBar value={clampedProgress} />
<small>{Math.round(clampedProgress)}%{eta ? ` | ETA ${eta}` : ''}</small>
</>
) : null}
</div>
{!isFinished ? (
<Button
label="Abbrechen"
icon="pi pi-times"
severity="danger"
outlined
onClick={() => onCancel && onCancel()}
disabled={busy}
/>
) : null}
</div>
);
}
return (
<div className="cd-rip-config-panel">
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
{selectedMeta.title ? (
<div className="cd-meta-summary">
<strong>{selectedMeta.artist ? `${selectedMeta.artist} ` : ''}{selectedMeta.title}</strong>
{selectedMeta.year ? <span> ({selectedMeta.year})</span> : null}
</div>
) : null}
{/* Format selection */}
<div className="cd-format-field">
<label>Ausgabeformat</label>
<Dropdown
value={format}
options={CD_FORMATS}
optionLabel="label"
optionValue="value"
onChange={(e) => setFormat(e.value)}
disabled={busy}
/>
</div>
{/* Format-specific options */}
{visibleFields.map((field) => (
<FormatField
key={field.key}
field={field}
value={formatOptions[field.key] ?? field.default}
onChange={handleFormatOptionChange}
/>
))}
{/* Track selection */}
{tracks.length > 0 ? (
<div className="cd-track-selection">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<strong>Tracks ({selectedCount} / {tracks.length} ausgewählt)</strong>
<Button
label={selectedCount === tracks.length ? 'Alle abwählen' : 'Alle auswählen'}
size="small"
severity="secondary"
outlined
onClick={handleToggleAll}
disabled={busy}
/>
</div>
<div className="cd-track-list">
{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 (
<button
key={track.position}
type="button"
className={`cd-track-row selectable${isSelected ? ' selected' : ''}`}
onClick={() => !busy && handleToggleTrack(track.position)}
disabled={busy}
>
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
<span className="cd-track-title">{track.title || `Track ${track.position}`}</span>
<span className="cd-track-duration">{duration}</span>
</button>
);
})}
</div>
</div>
) : null}
{/* Actions */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={() => onCancel && onCancel()}
disabled={busy}
/>
<Button
label="Rip starten"
icon="pi pi-play"
onClick={handleStart}
loading={busy}
disabled={selectedCount === 0}
/>
</div>
</div>
);
}

View File

@@ -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 (V0V9)',
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: 96192 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: 57.',
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;
}

View File

@@ -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'
if (mediaType === 'bluray') {
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
}
: mediaType === 'dvd'
? {
mediaType,
src: discIndicatorIcon,
alt: 'DVD',
title: 'DVD'
if (mediaType === 'dvd') {
return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' };
}
: {
mediaType,
src: otherIndicatorIcon,
alt: 'Sonstiges Medium',
title: 'Sonstiges Medium'
};
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)}
/>
</div>
{(() => {
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const isCdJob = jobState.startsWith('CD_');
if (isCdJob) {
return (
<>
{jobState === 'CD_METADATA_SELECTION' ? (
<Button
label="CD-Metadaten auswählen"
icon="pi pi-list"
onClick={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
: pipeline?.context || {};
setCdMetadataDialogContext({ ...ctx, jobId });
setCdMetadataDialogVisible(true);
}}
disabled={busyJobIds.has(jobId)}
/>
) : null}
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
busy={busyJobIds.has(jobId)}
/>
) : null}
</>
);
}
return null;
})()}
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
<PipelineStatusCard
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
@@ -2051,6 +2147,7 @@ export default function DashboardPage({
busy={busyJobIds.has(jobId)}
liveJobLog={isCurrentSession ? liveJobLog : ''}
/>
) : null}
</div>
);
}
@@ -2165,6 +2262,18 @@ export default function DashboardPage({
busy={busy}
/>
<CdMetadataDialog
visible={cdMetadataDialogVisible}
context={cdMetadataDialogContext || pipeline?.context || {}}
onHide={() => {
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
}}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearch}
busy={busy}
/>
<Dialog
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
visible={Boolean(cancelCleanupDialog.visible)}

View File

@@ -12,7 +12,12 @@ const STATUS_LABELS = {
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen',
ERROR: 'Fehler'
ERROR: 'Fehler',
CD_ANALYZING: 'CD-Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit zum Rippen',
CD_RIPPING: 'CD rippen',
CD_ENCODING: 'CD encodieren'
};
const PROCESS_STATUS_LABELS = {
@@ -46,6 +51,8 @@ export function getStatusSeverity(status, options = {}) {
if (normalized === 'ERROR') return 'danger';
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
if (normalized === 'CD_READY_TO_RIP') return 'info';
if (normalized === 'CD_METADATA_SELECTION') return 'warning';
if (
normalized === 'RIPPING'
|| normalized === 'ENCODING'
@@ -53,6 +60,9 @@ export function getStatusSeverity(status, options = {}) {
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'POST_ENCODE_SCRIPTS'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING'
) {
return 'warning';
}

View File

@@ -359,6 +359,16 @@ apt-get install -y \
ok "Basispakete installiert"
info "Installiere CD-Ripping-Tools..."
apt-get install -y \
cdparanoia \
flac \
lame \
opus-tools \
vorbis-tools
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
install_node
if [[ "$SKIP_MAKEMKV" == false ]]; then
@@ -434,6 +444,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
mkdir -p "$INSTALL_DIR/backend/logs"
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/logs"
# Gesicherte Daten zurückspielen
@@ -676,6 +687,11 @@ missing_tools=()
command_exists makemkvcon || missing_tools+=("makemkvcon")
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
command_exists mediainfo || missing_tools+=("mediainfo")
command_exists cdparanoia || missing_tools+=("cdparanoia")
command_exists flac || missing_tools+=("flac")
command_exists lame || missing_tools+=("lame")
command_exists opusenc || missing_tools+=("opusenc")
command_exists oggenc || missing_tools+=("oggenc")
if [[ ${#missing_tools[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"