Prototype

This commit is contained in:
2026-03-12 10:15:50 +00:00
parent 3dd043689e
commit 5cf869eaca
13 changed files with 1440 additions and 240 deletions

View File

@@ -56,6 +56,21 @@ router.get(
})
);
router.get(
'/cd/musicbrainz/release/:mbId',
asyncHandler(async (req, res) => {
const mbId = String(req.params.mbId || '').trim();
if (!mbId) {
const error = new Error('mbId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId });
const release = await pipelineService.getMusicBrainzReleaseById(mbId);
res.json({ release });
})
);
router.post(
'/cd/select-metadata',
asyncHandler(async (req, res) => {

View File

@@ -2,7 +2,6 @@ const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const settingsService = require('./settingsService');
const logger = require('./logger').child('CD_RIP');
const { spawnTrackedProcess } = require('./processRunner');
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
@@ -12,35 +11,80 @@ const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
/**
* Parse cdparanoia -Q output (stderr) to extract track information.
* Example output:
* Table of contents (start sector, length [start sector, length]):
* track 1: 0 (00:00.00) 24218 (05:22.43)
* track 2: 24218 (05:22.43) 15120 (03:21.20)
* TOTAL 193984 (43:04.59)
* Parse cdparanoia -Q output to extract track information.
* Supports both bracket styles shown by different builds:
* track 1: 0 (00:00.00) 24218 (05:22.43)
* track 1: 0 [00:00.00] 24218 [05:22.43]
*/
function parseToc(tocOutput) {
const lines = String(tocOutput || '').split(/\r?\n/);
const tracks = [];
for (const line of lines) {
const m = line.match(/^\s*track\s+(\d+)\s*:\s*(\d+)\s+\((\d+):(\d+)\.(\d+)\)\s+(\d+)\s+\((\d+):(\d+)\.(\d+)\)/i);
if (!m) {
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
if (trackMatch) {
const position = Number(trackMatch[1]);
const payloadWithoutTimes = String(trackMatch[2] || '')
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
if (sectorValues.length < 2) {
continue;
}
const startSector = Number(sectorValues[0]);
const lengthSector = Number(sectorValues[1]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
// duration in seconds: sectors / 75
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
continue;
}
const startSector = Number(m[2]);
const lengthSector = Number(m[6]);
// duration in seconds: sectors / 75
// Alternative cdparanoia -Q table style:
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
// ^ length sectors ^ start sector
const tableMatch = line.match(
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
);
if (!tableMatch) {
continue;
}
const position = Number(tableMatch[1]);
const lengthSector = Number(tableMatch[2]);
const startSector = Number(tableMatch[3]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position: Number(m[1]),
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
}
return tracks;
}
@@ -48,19 +92,20 @@ async function readToc(devicePath, cmd) {
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
logger.info('toc:read', { devicePath, cmd: cdparanoia });
try {
// cdparanoia -Q writes to stderr, exits 0 on success
const { stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
// Depending on distro/build, TOC can appear on stderr and/or stdout.
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
timeout: 15000
});
const tracks = parseToc(stderr);
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
logger.info('toc:done', { devicePath, trackCount: tracks.length });
return tracks;
} catch (error) {
// cdparanoia -Q exits non-zero sometimes even on success; try parsing stderr
// cdparanoia -Q may exit non-zero even when TOC is readable.
const stderr = String(error?.stderr || '');
const tracks = parseToc(stderr);
const stdout = String(error?.stdout || '');
const tracks = parseToc(`${stderr}\n${stdout}`);
if (tracks.length > 0) {
logger.info('toc:done-from-stderr', { devicePath, trackCount: tracks.length });
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
return tracks;
}
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
@@ -68,21 +113,247 @@ async function readToc(devicePath, cmd) {
}
}
function buildOutputFilename(track, meta, format) {
const ext = format === 'wav' ? 'wav' : format;
const num = String(track.position).padStart(2, '0');
const trackTitle = (track.title || `Track ${track.position}`)
.replace(/[/\\?%*:|"<>]/g, '-')
.trim();
return `${num}. ${trackTitle}.${ext}`;
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
return `${relativeBasePath}.${ext}`;
}
function buildOutputDir(meta, baseDir) {
const artist = (meta?.artist || 'Unknown Artist').replace(/[/\\?%*:|"<>]/g, '-').trim();
const album = (meta?.title || 'Unknown Album').replace(/[/\\?%*:|"<>]/g, '-').trim();
const year = meta?.year ? ` (${meta.year})` : '';
const folderName = `${artist} - ${album}${year}`;
return path.join(baseDir, folderName);
function sanitizePathSegment(value, fallback = 'unknown') {
const raw = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[\\/:*?"<>|]/g, '-')
// Keep umlauts/special letters, but filter heart symbols in filenames.
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!raw || raw === '.' || raw === '..') {
return fallback;
}
return raw.slice(0, 180);
}
function normalizeTemplateTokenKey(rawKey) {
const key = String(rawKey || '').trim().toLowerCase();
if (!key) {
return '';
}
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
return 'trackNr';
}
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
return 'trackNo';
}
if (key === 'trackartist' || key === 'track_artist') {
return 'trackArtist';
}
if (key === 'albumartist') {
return 'albumArtist';
}
if (key === 'interpret') {
return 'artist';
}
return key;
}
function cleanupRenderedTemplate(value) {
return String(value || '')
.replace(/\(\s*\)/g, '')
.replace(/\[\s*]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function renderOutputTemplate(template, values) {
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
const rawValue = values[normalizedKey];
if (rawValue === undefined || rawValue === null) {
return '';
}
return String(rawValue);
});
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(track, meta, format = null) {
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
return {
artist: albumArtist,
albumArtist,
trackArtist,
album,
year,
title: trackTitle,
trackNr: String(trackNo).padStart(2, '0'),
trackNo: String(trackNo),
format: format ? String(format).trim().toLowerCase() : ''
};
}
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
const values = buildTemplateValues(track, meta, format);
const rendered = renderOutputTemplate(outputTemplate, values)
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '');
const parts = rendered
.split('/')
.map((part) => sanitizePathSegment(part, 'unknown'))
.filter(Boolean);
if (parts.length === 0) {
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
}
return path.join(...parts);
}
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const sampleTrack = {
position: 1,
title: 'Track 1'
};
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
const relativeDir = path.dirname(relativeBasePath);
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
return baseDir;
}
return path.join(baseDir, relativeDir);
}
function splitPathSegments(value) {
return String(value || '')
.replace(/\\/g, '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
}
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
const outputSegments = splitPathSegments(outputBaseDir);
const relativeSegments = splitPathSegments(relativeDir);
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
return false;
}
const offset = outputSegments.length - relativeSegments.length;
for (let i = 0; i < relativeSegments.length; i++) {
if (outputSegments[offset + i] !== relativeSegments[i]) {
return false;
}
}
return true;
}
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
const fileSegments = splitPathSegments(relativeFilePath);
const dirSegments = splitPathSegments(relativeDir);
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
return relativeFilePath;
}
for (let i = 0; i < dirSegments.length; i++) {
if (fileSegments[i] !== dirSegments[i]) {
return relativeFilePath;
}
}
return path.join(...fileSegments.slice(dirSegments.length));
}
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
const relativeDir = path.dirname(relativeBasePath);
let relativeFilePath = `${relativeBasePath}.${ext}`;
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
}
}
const outFile = path.join(outputBaseDir, relativeFilePath);
return {
outFile,
relativeFilePath,
outFilename: path.basename(relativeFilePath)
};
}
function buildCancelledError() {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
return error;
}
function assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
}
function normalizeExitCode(error) {
const code = Number(error?.code);
if (Number.isFinite(code)) {
return Math.trunc(code);
}
return 1;
}
function quoteShellArg(value) {
const text = String(value == null ? '' : value);
if (!text) {
return "''";
}
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, "'\\''")}'`;
}
function formatCommandLine(cmd, args = []) {
const normalizedArgs = Array.isArray(args) ? args : [];
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
}
async function runProcessTracked({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context,
onProcessHandle,
isCancelled
}) {
assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
throw error;
}
}
/**
@@ -99,8 +370,11 @@ function buildOutputDir(meta, baseDir) {
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
* @param {object} options.meta - album metadata {title, artist, year}
* @param {string} options.outputTemplate - template for relative output path without extension
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
* @param {Function} options.onLog - (level, msg) => void
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
* @param {Function} options.isCancelled - returns true when user requested cancellation
* @param {object} options.context - passed to spawnTrackedProcess
*/
async function ripAndEncode(options) {
@@ -115,8 +389,11 @@ async function ripAndEncode(options) {
selectedTracks = [],
tracks = [],
meta = {},
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
onProgress,
onLog,
onProcessHandle,
isCancelled,
context
} = options;
@@ -149,37 +426,42 @@ async function ripAndEncode(options) {
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
log('info', `Rippe Track ${track.position} von ${tracks.length}`);
log('info', `Rippe Track ${track.position} von ${tracksToRip.length}`);
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
let lastTrackPercent = 0;
const runInfo = await spawnTrackedProcess({
cmd: cdparanoiaCmd,
args: ['-d', devicePath, String(track.position), wavFile],
cwd: rawWavDir,
onStderrLine(line) {
const parsed = parseCdParanoiaProgress(line);
if (parsed && parsed.percent !== null) {
lastTrackPercent = parsed.percent;
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
onProgress && onProgress({
phase: 'rip',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
percent: overallPercent
});
}
},
context
});
if (runInfo.exitCode !== 0) {
try {
await runProcessTracked({
cmd: cdparanoiaCmd,
args: ripArgs,
cwd: rawWavDir,
onStderrLine(line) {
const parsed = parseCdParanoiaProgress(line);
if (parsed && parsed.percent !== null) {
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
onProgress && onProgress({
phase: 'rip',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
percent: overallPercent
});
}
},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
throw new Error(
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${runInfo.exitCode})`
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
@@ -198,9 +480,12 @@ async function ripAndEncode(options) {
if (format === 'wav') {
// Just move WAV files to output dir with proper names
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
const outFile = path.join(outputDir, buildOutputFilename(track, meta, 'wav'));
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
ensureDir(path.dirname(outFile));
log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
fs.renameSync(wavFile, outFile);
onProgress && onProgress({
phase: 'encode',
@@ -215,6 +500,7 @@ async function ripAndEncode(options) {
}
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
@@ -222,21 +508,33 @@ async function ripAndEncode(options) {
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
}
const outFilename = buildOutputFilename(track, meta, format);
const outFile = path.join(outputDir, outFilename);
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
ensureDir(path.dirname(outFile));
log('info', `Encodiere Track ${track.position}${outFilename}`);
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
await spawnTrackedProcess({
cmd: encodeArgs.cmd,
args: encodeArgs.args,
cwd: rawWavDir,
onStdoutLine() {},
onStderrLine() {},
context
});
try {
await runProcessTracked({
cmd: encodeArgs.cmd,
args: encodeArgs.args,
cwd: rawWavDir,
onStdoutLine() {},
onStderrLine() {},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
throw new Error(
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
// Clean up WAV after encode
try {
@@ -260,7 +558,7 @@ async function ripAndEncode(options) {
}
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
const artist = meta?.artist || '';
const artist = track?.artist || meta?.artist || '';
const album = meta?.title || '';
const year = meta?.year ? String(meta.year) : '';
const trackTitle = track.title || `Track ${track.position}`;
@@ -346,8 +644,11 @@ function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
}
module.exports = {
parseToc,
readToc,
ripAndEncode,
buildOutputDir,
buildOutputFilename,
DEFAULT_CD_OUTPUT_TEMPLATE,
SUPPORTED_FORMATS
};

View File

@@ -4,6 +4,7 @@ const { execFile } = require('child_process');
const { promisify } = require('util');
const settingsService = require('./settingsService');
const logger = require('./logger').child('DISK');
const { parseToc } = require('./cdRipService');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
@@ -541,15 +542,28 @@ class DiskDetectionService extends EventEmitter {
// Last resort: cdparanoia can read the TOC of audio CDs directly.
// Useful when udev media flags are not propagated (e.g. VM passthrough).
// blkid already returned nothing above, so a successful cdparanoia -Q
// means an audio CD is present (data discs have no filesystem but blkid
// would still have caught them via sector probing).
// Some builds return non-zero even when TOC output exists, so parse both
// stdout/stderr and treat valid TOC lines as "audio CD present".
// Keep compatibility with previous behavior: exit 0 counts as media even
// when TOC output format cannot be parsed.
try {
await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
logger.debug('cdparanoia:audio-cd', { devicePath });
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
if (tracks.length > 0) {
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
return { hasMedia: true, type: 'audio_cd' };
}
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
return { hasMedia: true, type: 'audio_cd' };
} catch (_cdError) {
// cdparanoia failed no audio CD present (or cdparanoia not installed)
} catch (cdError) {
const stderr = String(cdError?.stderr || '');
const stdout = String(cdError?.stdout || '');
const tracks = parseToc(`${stderr}\n${stdout}`);
if (tracks.length > 0) {
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
return { hasMedia: true, type: 'audio_cd' };
}
// cdparanoia failed and no TOC output could be parsed.
}
logger.debug('blkid:no-media-or-fail', { devicePath });

View File

@@ -39,14 +39,40 @@ function normalizeRelease(release) {
const year = yearMatch ? Number(yearMatch[1]) : null;
const media = Array.isArray(release.media) ? release.media : [];
const tracks = media.flatMap((medium, mediumIdx) => {
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
return mediumTracks.map((track) => ({
position: Number(track.position || mediumIdx * 100 + 1),
return mediumTracks.map((track, trackIdx) => {
const rawPosition = String(track.position || track.number || '').trim();
const parsedPosition = Number.parseInt(rawPosition, 10);
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
? parsedPosition
: fallbackPosition;
return {
position,
number: String(track.number || track.position || ''),
title: String(track.title || ''),
durationMs: Number(track.length || 0) || null
}));
durationMs: Number(track.length || 0) || null,
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
};
});
}).map((track) => {
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
? track.rawTrackArtistCredit
: [];
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
? track.rawRecordingArtistCredit
: [];
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
return {
position: track.position,
number: track.number,
title: track.title,
durationMs: track.durationMs,
artist: artistFromTrack || artistFromRecording || artistCredit || null
};
});
// Always generate the CAA URL when an id is present; the browser/onError
@@ -66,7 +92,7 @@ function normalizeRelease(release) {
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null,
coverArtUrl,
tracks
tracks: normalizedTracks
};
}
@@ -93,7 +119,7 @@ class MusicBrainzService {
url.searchParams.set('query', q);
url.searchParams.set('fmt', 'json');
url.searchParams.set('limit', '10');
url.searchParams.set('inc', 'artist-credits+labels+recordings');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
@@ -126,7 +152,7 @@ class MusicBrainzService {
const url = new URL(`${MB_BASE}/release/${id}`);
url.searchParams.set('fmt', 'json');
url.searchParams.set('inc', 'artist-credits+labels+recordings+cover-art-archive');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());

View File

@@ -74,6 +74,16 @@ function nowIso() {
return new Date().toISOString();
}
function normalizeCdTrackText(value) {
return String(value || '')
.normalize('NFC')
// Keep umlauts/special letters, but strip heart symbols from imported metadata.
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeMediaProfile(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
@@ -9793,11 +9803,17 @@ class PipelineService extends EventEmitter {
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length });
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
error.statusCode = 400;
throw error;
}
const cdInfo = {
phase: 'PREPARE',
mediaProfile: 'cd',
preparedAt: nowIso(),
cdparanoiaCmd,
tracks,
detectedTitle
};
@@ -9817,6 +9833,8 @@ class PipelineService extends EventEmitter {
const runningJobs = await historyService.getRunningJobs();
const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id));
if (!foreignRunningJobs.length) {
const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null;
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
await this.setState('CD_METADATA_SELECTION', {
activeJobId: job.id,
progress: 0,
@@ -9826,6 +9844,9 @@ class PipelineService extends EventEmitter {
jobId: job.id,
device,
mediaProfile: 'cd',
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
detectedTitle,
tracks
}
@@ -9847,6 +9868,24 @@ class PipelineService extends EventEmitter {
return results;
}
async getMusicBrainzReleaseById(mbId) {
const id = String(mbId || '').trim();
if (!id) {
const error = new Error('mbId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('musicbrainz:get-by-id', { mbId: id });
const release = await musicBrainzService.getReleaseById(id);
if (!release) {
const error = new Error(`MusicBrainz Release ${id} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
logger.info('musicbrainz:get-by-id:done', { mbId: id, trackCount: Array.isArray(release.tracks) ? release.tracks.length : 0 });
return release;
}
async selectCdMetadata(payload) {
const {
jobId,
@@ -9881,9 +9920,12 @@ class PipelineService extends EventEmitter {
const selected = Array.isArray(selectedTracks)
? selectedTracks.find((st) => Number(st.position) === Number(t.position))
: null;
const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`;
const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null;
return {
...t,
title: selected?.title || t.title || `Track ${t.position}`,
title: resolvedTitle,
artist: resolvedArtist,
selected: selected ? Boolean(selected.selected) : true
};
});
@@ -9909,6 +9951,10 @@ class PipelineService extends EventEmitter {
);
if (this.isPrimaryJob(jobId)) {
const resolvedDevicePath = String(job?.disc_device || this.snapshot?.context?.device?.path || '').trim() || null;
const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null;
const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
await this.setState('CD_READY_TO_RIP', {
activeJobId: jobId,
progress: 0,
@@ -9919,7 +9965,10 @@ class PipelineService extends EventEmitter {
jobId,
mediaProfile: 'cd',
tracks: mergedTracks,
selectedMetadata: { title, artist, year, mbId, coverUrl }
selectedMetadata: { title, artist, year, mbId, coverUrl },
devicePath: resolvedDevicePath,
cdparanoiaCmd: resolvedCdparanoiaCmd,
cdparanoiaCommandPreview
}
});
}
@@ -9949,27 +9998,119 @@ class PipelineService extends EventEmitter {
const format = String(ripConfig?.format || 'flac').trim().toLowerCase();
const formatOptions = ripConfig?.formatOptions || {};
const normalizeTrackPosition = (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
};
const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks)
? ripConfig.selectedTracks.map(Number).filter(Number.isFinite)
? ripConfig.selectedTracks
.map(normalizeTrackPosition)
.filter((value) => Number.isFinite(value) && value > 0)
: [];
const normalizeOptionalYear = (value) => {
if (value === null || value === undefined || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
};
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
const incomingTracks = Array.isArray(ripConfig?.tracks) ? ripConfig.tracks : [];
const incomingByPosition = new Map();
for (const incoming of incomingTracks) {
const position = normalizeTrackPosition(incoming?.position);
if (!position) {
continue;
}
incomingByPosition.set(position, incoming);
}
const selectedMeta = cdInfo.selectedMetadata || {};
const incomingMeta = ripConfig?.metadata && typeof ripConfig.metadata === 'object'
? ripConfig.metadata
: {};
const effectiveSelectedMeta = {
...selectedMeta,
title: normalizeCdTrackText(incomingMeta?.title)
|| normalizeCdTrackText(selectedMeta?.title)
|| normalizeCdTrackText(job?.title)
|| normalizeCdTrackText(cdInfo?.detectedTitle)
|| 'Audio CD',
artist: normalizeCdTrackText(incomingMeta?.artist)
|| normalizeCdTrackText(selectedMeta?.artist)
|| null,
year: normalizeOptionalYear(incomingMeta?.year)
?? normalizeOptionalYear(selectedMeta?.year)
?? normalizeOptionalYear(job?.year)
?? null
};
const mergedTracks = tocTracks.map((track) => {
const position = normalizeTrackPosition(track?.position);
if (!position) {
return null;
}
const incoming = incomingByPosition.get(position) || null;
const fallbackTitle = normalizeCdTrackText(track?.title) || `Track ${position}`;
const fallbackArtist = normalizeCdTrackText(track?.artist) || normalizeCdTrackText(effectiveSelectedMeta?.artist) || '';
const title = normalizeCdTrackText(incoming?.title) || fallbackTitle;
const artist = normalizeCdTrackText(incoming?.artist) || fallbackArtist || null;
const selected = incoming
? Boolean(incoming?.selected)
: (track?.selected !== false);
return {
...track,
position,
title,
artist,
selected
};
}).filter(Boolean);
const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0
? selectedTrackPositions
: mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position);
const settings = await settingsService.getEffectiveSettingsMap('cd');
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const rawBaseDir = String(settings.raw_dir || 'data/output/raw').trim();
const cdOutputTemplate = String(
settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
const cdBaseDir = String(settings.raw_dir_cd || '').trim() || 'data/output/cd';
const jobDir = `CD_Job${jobId}_${Date.now()}`;
const rawWavDir = path.join(rawBaseDir, jobDir, 'wav');
const outputDir = cdRipService.buildOutputDir(selectedMeta, path.join(rawBaseDir, jobDir));
const rawWavDir = path.join(cdBaseDir, '.tmp', jobDir, 'wav');
const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate);
const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1;
const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`);
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath} ${previewTrackPos} ${previewWavPath}`;
const updatedCdInfo = {
...cdInfo,
tracks: mergedTracks,
selectedMetadata: effectiveSelectedMeta
};
await historyService.updateJob(jobId, {
title: effectiveSelectedMeta?.title || null,
year: normalizeOptionalYear(effectiveSelectedMeta?.year),
status: 'CD_RIPPING',
last_state: 'CD_RIPPING',
error_message: null,
raw_path: rawWavDir,
raw_path: null,
output_path: outputDir,
encode_plan_json: JSON.stringify({ format, formatOptions, selectedTracks: selectedTrackPositions })
makemkv_info_json: JSON.stringify(updatedCdInfo),
encode_plan_json: JSON.stringify({
format,
formatOptions,
selectedTracks: effectiveSelectedTrackPositions,
tracks: mergedTracks,
outputTemplate: cdOutputTemplate
})
});
await this.setState('CD_RIPPING', {
@@ -9981,16 +10122,25 @@ class PipelineService extends EventEmitter {
...(this.snapshot.context || {}),
jobId,
mediaProfile: 'cd',
selectedMetadata: selectedMeta
tracks: mergedTracks,
selectedMetadata: effectiveSelectedMeta,
devicePath,
cdparanoiaCmd,
rawWavDir,
outputTemplate: cdOutputTemplate,
cdparanoiaCommandPreview
}
});
logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: selectedTrackPositions.length });
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip gestartet: Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}`);
logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: effectiveSelectedTrackPositions.length });
await historyService.appendLog(
jobId,
'SYSTEM',
`CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}`
);
// Run asynchronously so the HTTP response returns immediately
this._runCdRip({
job,
jobId,
devicePath,
cdparanoiaCmd,
@@ -9998,9 +10148,10 @@ class PipelineService extends EventEmitter {
outputDir,
format,
formatOptions,
selectedTrackPositions,
tocTracks,
selectedMeta
outputTemplate: cdOutputTemplate,
selectedTrackPositions: effectiveSelectedTrackPositions,
tocTracks: mergedTracks,
selectedMeta: effectiveSelectedMeta
}).catch((error) => {
logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) });
});
@@ -10009,7 +10160,6 @@ class PipelineService extends EventEmitter {
}
async _runCdRip({
job,
jobId,
devicePath,
cdparanoiaCmd,
@@ -10017,14 +10167,54 @@ class PipelineService extends EventEmitter {
outputDir,
format,
formatOptions,
outputTemplate,
selectedTrackPositions,
tocTracks,
selectedMeta
}) {
const processKey = Number(jobId);
this.activeProcesses.set(processKey, { cancel: () => {} });
let currentProcessHandle = null;
let lifecycleResolve = null;
let lifecycleSettled = false;
const lifecyclePromise = new Promise((resolve) => {
lifecycleResolve = resolve;
});
const settleLifecycle = () => {
if (lifecycleSettled) {
return;
}
lifecycleSettled = true;
lifecycleResolve({ settled: true });
};
const sharedHandle = {
child: null,
promise: lifecyclePromise,
cancel: () => {
try {
currentProcessHandle?.cancel?.();
} catch (_error) {
// ignore cancel race errors
}
}
};
this.activeProcesses.set(processKey, sharedHandle);
this.syncPrimaryActiveProcess();
try {
let encodeStateApplied = false;
let lastProgressPercent = 0;
const bindProcessHandle = (handle) => {
currentProcessHandle = handle && typeof handle === 'object' ? handle : null;
sharedHandle.child = currentProcessHandle?.child || null;
this.syncPrimaryActiveProcess();
if (this.cancelRequestedByJob.has(processKey)) {
try {
currentProcessHandle?.cancel?.();
} catch (_error) {
// ignore cancel race errors
}
}
};
await cdRipService.ripAndEncode({
jobId,
devicePath,
@@ -10033,34 +10223,49 @@ class PipelineService extends EventEmitter {
outputDir,
format,
formatOptions,
outputTemplate,
selectedTracks: selectedTrackPositions,
tracks: tocTracks,
meta: selectedMeta,
onProgress: async ({ phase, percent }) => {
const clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0));
const statusText = phase === 'rip' ? 'CD wird gerippt …' : 'Tracks werden encodiert …';
const newState = phase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING';
onProcessHandle: bindProcessHandle,
isCancelled: () => this.cancelRequestedByJob.has(processKey),
onProgress: async ({ phase, percent, trackIndex, trackTotal }) => {
const normalizedPhase = phase === 'encode' ? 'encode' : 'rip';
const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING';
let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0));
if (normalizedPhase === 'rip') {
clampedPercent = Math.min(clampedPercent, 50);
} else {
clampedPercent = Math.max(50, clampedPercent);
}
if (clampedPercent < lastProgressPercent) {
clampedPercent = lastProgressPercent;
}
lastProgressPercent = clampedPercent;
if (phase === 'encode' && this.snapshot.state === 'CD_RIPPING') {
if (normalizedPhase === 'encode' && !encodeStateApplied) {
encodeStateApplied = true;
await historyService.updateJob(jobId, {
status: 'CD_ENCODING',
last_state: 'CD_ENCODING'
});
}
await this.setState(newState, {
activeJobId: jobId,
progress: clampedPercent,
eta: null,
statusText,
context: this.snapshot.context
});
const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0
? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})`
: '';
const statusText = normalizedPhase === 'rip'
? `CD wird gerippt …${detail}`
: `Tracks werden encodiert …${detail}`;
await this.updateProgress(stage, clampedPercent, null, statusText, processKey);
},
onLog: async (level, msg) => {
await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
},
context: { jobId: processKey }
});
settleLifecycle();
// Success
await historyService.updateJob(jobId, {
@@ -10090,10 +10295,20 @@ class PipelineService extends EventEmitter {
message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}`
});
} catch (error) {
settleLifecycle();
logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) });
await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error);
} finally {
try {
const cdTempJobDir = path.dirname(String(rawWavDir || ''));
if (cdTempJobDir && cdTempJobDir !== '.' && cdTempJobDir !== path.sep) {
fs.rmSync(cdTempJobDir, { recursive: true, force: true });
}
} catch (_cleanupError) {
// ignore temp cleanup issues
}
this.activeProcesses.delete(processKey);
this.syncPrimaryActiveProcess();
}
}