Prototype
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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]):
|
||||
* 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 2: 24218 (05:22.43) 15120 (03:21.20)
|
||||
* TOTAL 193984 (43:04.59)
|
||||
* 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(m[2]);
|
||||
const lengthSector = Number(m[6]);
|
||||
|
||||
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: Number(m[1]),
|
||||
position,
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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,
|
||||
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,21 +426,22 @@ 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({
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: cdparanoiaCmd,
|
||||
args: ['-d', devicePath, String(track.position), wavFile],
|
||||
args: ripArgs,
|
||||
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',
|
||||
@@ -174,12 +452,16 @@ async function ripAndEncode(options) {
|
||||
});
|
||||
}
|
||||
},
|
||||
context
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
});
|
||||
|
||||
if (runInfo.exitCode !== 0) {
|
||||
} 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({
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: encodeArgs.cmd,
|
||||
args: encodeArgs.args,
|
||||
cwd: rawWavDir,
|
||||
onStdoutLine() {},
|
||||
onStderrLine() {},
|
||||
context
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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' };
|
||||
} catch (_cdError) {
|
||||
// cdparanoia failed – no audio CD present (or cdparanoia not installed)
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
} 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 });
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,22 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES (
|
||||
'cd_output_template',
|
||||
'Tools',
|
||||
'CD Output Template',
|
||||
'string',
|
||||
1,
|
||||
'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt.',
|
||||
'{artist} - {album} ({year})/{trackNr} {artist} - {title}',
|
||||
'[]',
|
||||
'{"minLength":1}',
|
||||
235
|
||||
);
|
||||
INSERT OR IGNORE INTO settings_values (key, value)
|
||||
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
|
||||
|
||||
-- Pfade – CD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_cd', 'Pfade', 'CD Ausgabeordner', 'path', 0, 'Optionaler Ausgabeordner für geripppte CD-Dateien. Leer = Fallback auf "Raw Ausgabeordner".', '/opt/ripster/backend/data/output/cd', '[]', '{}', 104);
|
||||
|
||||
@@ -276,6 +276,9 @@ export const api = {
|
||||
searchMusicBrainz(q) {
|
||||
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
getMusicBrainzRelease(mbId) {
|
||||
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
|
||||
},
|
||||
async selectCdMetadata(payload) {
|
||||
const result = await request('/pipeline/cd/select-metadata', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
|
||||
function CoverThumb({ url, alt }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [url]);
|
||||
if (!url || failed) {
|
||||
return <div className="poster-thumb-lg poster-fallback">-</div>;
|
||||
}
|
||||
@@ -17,16 +19,46 @@ function CoverThumb({ url, alt }) {
|
||||
src={url}
|
||||
alt={alt}
|
||||
className="poster-thumb-lg"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDurationMs(ms) {
|
||||
const totalSec = Math.round((ms || 0) / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${String(sec).padStart(2, '0')}`;
|
||||
const COVER_PRELOAD_TIMEOUT_MS = 3000;
|
||||
|
||||
function preloadCoverImage(url) {
|
||||
const src = String(url || '').trim();
|
||||
if (!src) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
const done = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
|
||||
image.onload = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.onerror = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CdMetadataDialog({
|
||||
@@ -35,20 +67,22 @@ export default function CdMetadataDialog({
|
||||
onHide,
|
||||
onSubmit,
|
||||
onSearch,
|
||||
onFetchRelease,
|
||||
busy
|
||||
}) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const searchRunRef = useRef(0);
|
||||
|
||||
// Manual metadata inputs
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualArtist, setManualArtist] = useState('');
|
||||
const [manualYear, setManualYear] = useState(null);
|
||||
|
||||
// Per-track title editing
|
||||
// Track titles are pre-filled from MusicBrainz and edited in the next step.
|
||||
const [trackTitles, setTrackTitles] = useState({});
|
||||
const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set());
|
||||
|
||||
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||
|
||||
@@ -57,20 +91,18 @@ export default function CdMetadataDialog({
|
||||
return;
|
||||
}
|
||||
setSelected(null);
|
||||
setQuery(context?.detectedTitle || '');
|
||||
setQuery('');
|
||||
setManualTitle(context?.detectedTitle || '');
|
||||
setManualArtist('');
|
||||
setManualYear(null);
|
||||
setResults([]);
|
||||
setSearchBusy(false);
|
||||
|
||||
const titles = {};
|
||||
const positions = new Set();
|
||||
for (const t of tocTracks) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
positions.add(t.position);
|
||||
}
|
||||
setTrackTitles(titles);
|
||||
setSelectedTrackPositions(positions);
|
||||
}, [visible, context]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,48 +132,79 @@ export default function CdMetadataDialog({
|
||||
}, [selected]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
const searchResults = await onSearch(query.trim());
|
||||
setResults(searchResults || []);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleToggleTrack = (position) => {
|
||||
setSelectedTrackPositions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(position)) {
|
||||
next.delete(position);
|
||||
} else {
|
||||
next.add(position);
|
||||
setSearchBusy(true);
|
||||
const searchRunId = searchRunRef.current + 1;
|
||||
searchRunRef.current = searchRunId;
|
||||
try {
|
||||
const searchResults = await onSearch(trimmedQuery);
|
||||
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
|
||||
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
|
||||
if (searchRunRef.current !== searchRunId) {
|
||||
return;
|
||||
}
|
||||
setResults(normalizedResults);
|
||||
setSelected(null);
|
||||
} finally {
|
||||
if (searchRunRef.current === searchRunId) {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (selectedTrackPositions.size === tocTracks.length) {
|
||||
setSelectedTrackPositions(new Set());
|
||||
} else {
|
||||
setSelectedTrackPositions(new Set(tocTracks.map((t) => t.position)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const tracks = tocTracks.map((t) => ({
|
||||
position: t.position,
|
||||
title: trackTitles[t.position] || `Track ${t.position}`,
|
||||
selected: selectedTrackPositions.has(t.position)
|
||||
}));
|
||||
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
let releaseDetails = selected;
|
||||
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
|
||||
const fetched = await onFetchRelease(selected.mbId);
|
||||
if (fetched && typeof fetched === 'object') {
|
||||
releaseDetails = fetched;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
|
||||
const releaseTracksByPosition = new Map();
|
||||
releaseTracks.forEach((track, index) => {
|
||||
const parsedPosition = Number(track?.position);
|
||||
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
|
||||
? Math.trunc(parsedPosition)
|
||||
: index + 1;
|
||||
if (!releaseTracksByPosition.has(normalizedPosition)) {
|
||||
releaseTracksByPosition.set(normalizedPosition, track);
|
||||
}
|
||||
});
|
||||
|
||||
const tracks = tocTracks.map((t, index) => {
|
||||
const position = Number(t.position);
|
||||
const byPosition = releaseTracksByPosition.get(position);
|
||||
const byIndex = releaseTracks[index];
|
||||
return {
|
||||
position,
|
||||
title: normalizeTrackText(
|
||||
byPosition?.title
|
||||
|| byIndex?.title
|
||||
|| trackTitles[t.position]
|
||||
) || `Track ${t.position}`,
|
||||
artist: normalizeTrackText(
|
||||
byPosition?.artist
|
||||
|| byIndex?.artist
|
||||
|| manualArtist.trim()
|
||||
|| releaseDetails?.artist
|
||||
) || null,
|
||||
selected: true
|
||||
};
|
||||
});
|
||||
|
||||
const payload = {
|
||||
jobId: context.jobId,
|
||||
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
|
||||
artist: manualArtist.trim() || null,
|
||||
year: manualYear || null,
|
||||
mbId: selected?.mbId || null,
|
||||
coverUrl: selected?.coverArtUrl || null,
|
||||
mbId: releaseDetails?.mbId || selected?.mbId || null,
|
||||
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
|
||||
tracks
|
||||
};
|
||||
|
||||
@@ -159,9 +222,6 @@ export default function CdMetadataDialog({
|
||||
</div>
|
||||
);
|
||||
|
||||
const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length;
|
||||
const tracksBlocking = tocTracks.length > 0 && selectedTrackPositions.size === 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="CD-Metadaten auswählen"
|
||||
@@ -180,7 +240,12 @@ export default function CdMetadataDialog({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Album / Interpret suchen"
|
||||
/>
|
||||
<Button label="MusicBrainz Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
|
||||
<Button
|
||||
label="MusicBrainz Suche"
|
||||
icon="pi pi-search"
|
||||
onClick={handleSearch}
|
||||
loading={busy || searchBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 ? (
|
||||
@@ -226,42 +291,11 @@ export default function CdMetadataDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track selection */}
|
||||
{/* Track selection/editing moved to CD-Rip configuration panel */}
|
||||
{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>
|
||||
</>
|
||||
<small style={{ display: 'block', marginTop: '0.9rem' }}>
|
||||
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
|
||||
</small>
|
||||
) : null}
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
|
||||
@@ -271,7 +305,7 @@ export default function CdMetadataDialog({
|
||||
icon="pi pi-arrow-right"
|
||||
onClick={handleSubmit}
|
||||
loading={busy}
|
||||
disabled={tracksBlocking || (!manualTitle.trim() && !context?.detectedTitle)}
|
||||
disabled={!manualTitle.trim() && !context?.detectedTitle}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Slider } from 'primereact/slider';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function isFieldVisible(field, values) {
|
||||
if (!field.showWhen) {
|
||||
@@ -51,6 +54,147 @@ function FormatField({ field, value, onChange }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteShellArg(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) {
|
||||
return "''";
|
||||
}
|
||||
if (/^[a-zA-Z0-9_./:-]+$/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function buildCommandLine(cmd, args = []) {
|
||||
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||
}
|
||||
|
||||
function buildEncodeCommandPreview({
|
||||
format,
|
||||
formatOptions,
|
||||
wavFile,
|
||||
outFile,
|
||||
trackTitle,
|
||||
trackArtist,
|
||||
albumTitle,
|
||||
year,
|
||||
trackNo
|
||||
}) {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
const title = String(trackTitle || `Track ${trackNo || 1}`).trim() || `Track ${trackNo || 1}`;
|
||||
const artist = String(trackArtist || '').trim();
|
||||
const album = String(albumTitle || '').trim();
|
||||
const releaseYear = year == null ? '' : String(year).trim();
|
||||
const number = String(trackNo || 1);
|
||||
|
||||
if (normalizedFormat === 'wav') {
|
||||
return buildCommandLine('mv', [wavFile, outFile]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'flac') {
|
||||
const level = Math.max(0, Math.min(8, Number(formatOptions?.flacCompression ?? 5)));
|
||||
return buildCommandLine('flac', [
|
||||
`--compression-level-${level}`,
|
||||
'--tag', `TITLE=${title}`,
|
||||
'--tag', `ARTIST=${artist}`,
|
||||
'--tag', `ALBUM=${album}`,
|
||||
'--tag', `DATE=${releaseYear}`,
|
||||
'--tag', `TRACKNUMBER=${number}`,
|
||||
wavFile,
|
||||
'-o', outFile
|
||||
]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'mp3') {
|
||||
const mode = String(formatOptions?.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
const args = ['--id3v2-only', '--noreplaygain'];
|
||||
if (mode === 'vbr') {
|
||||
const quality = Math.max(0, Math.min(9, Number(formatOptions?.mp3Quality ?? 4)));
|
||||
args.push('-V', String(quality));
|
||||
} else {
|
||||
const bitrate = Number(formatOptions?.mp3Bitrate ?? 192);
|
||||
args.push('-b', String(bitrate));
|
||||
}
|
||||
args.push(
|
||||
'--tt', title,
|
||||
'--ta', artist,
|
||||
'--tl', album,
|
||||
'--ty', releaseYear,
|
||||
'--tn', number,
|
||||
wavFile,
|
||||
outFile
|
||||
);
|
||||
return buildCommandLine('lame', args);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'opus') {
|
||||
const bitrate = Math.max(32, Math.min(512, Number(formatOptions?.opusBitrate ?? 160)));
|
||||
const complexity = Math.max(0, Math.min(10, Number(formatOptions?.opusComplexity ?? 10)));
|
||||
return buildCommandLine('opusenc', [
|
||||
'--bitrate', String(bitrate),
|
||||
'--comp', String(complexity),
|
||||
'--title', title,
|
||||
'--artist', artist,
|
||||
'--album', album,
|
||||
'--date', releaseYear,
|
||||
'--tracknumber', number,
|
||||
wavFile,
|
||||
outFile
|
||||
]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'ogg') {
|
||||
const quality = Math.max(-1, Math.min(10, Number(formatOptions?.oggQuality ?? 6)));
|
||||
return buildCommandLine('oggenc', [
|
||||
'-q', String(quality),
|
||||
'-t', title,
|
||||
'-a', artist,
|
||||
'-l', album,
|
||||
'-d', releaseYear,
|
||||
'-N', number,
|
||||
'-o', outFile,
|
||||
wavFile
|
||||
]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePosition(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeTrackText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeYear(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatTrackDuration(track) {
|
||||
const durationMs = Number(track?.durationMs);
|
||||
const durationSec = Number(track?.durationSec);
|
||||
const totalSec = Number.isFinite(durationMs) && durationMs > 0
|
||||
? Math.round(durationMs / 1000)
|
||||
: (Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : 0);
|
||||
if (totalSec <= 0) {
|
||||
return '-';
|
||||
}
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function CdRipConfigPanel({
|
||||
pipeline,
|
||||
onStart,
|
||||
@@ -67,27 +211,117 @@ export default function CdRipConfigPanel({
|
||||
|
||||
const [format, setFormat] = useState('flac');
|
||||
const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac'));
|
||||
const [settingsCdparanoiaCmd, setSettingsCdparanoiaCmd] = useState('');
|
||||
|
||||
// Track selection: position → boolean
|
||||
const [selectedTracks, setSelectedTracks] = useState(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = true;
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
map[position] = t?.selected !== false;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
// Editable track metadata in job overview (artist/title).
|
||||
const [trackFields, setTrackFields] = useState(() => {
|
||||
const map = {};
|
||||
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
||||
for (const t of tracks) {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
const fallbackTitle = `Track ${position}`;
|
||||
map[position] = {
|
||||
title: normalizeTrackText(t?.title) || fallbackTitle,
|
||||
artist: normalizeTrackText(t?.artist) || defaultArtist
|
||||
};
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const [metaFields, setMetaFields] = useState(() => ({
|
||||
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
||||
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
||||
year: normalizeYear(selectedMeta?.year)
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setFormatOptions(getDefaultFormatOptions(format));
|
||||
}, [format]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = selectedTracks[t.position] !== false;
|
||||
setMetaFields({
|
||||
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
||||
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
||||
year: normalizeYear(selectedMeta?.year)
|
||||
});
|
||||
}, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshSettings = async () => {
|
||||
try {
|
||||
const response = await api.getSettings({ forceRefresh: true });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
}, [tracks.length]);
|
||||
const value = String(response?.settings?.cdparanoia_command || '').trim();
|
||||
setSettingsCdparanoiaCmd(value);
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setSettingsCdparanoiaCmd('');
|
||||
}
|
||||
}
|
||||
};
|
||||
refreshSettings();
|
||||
const intervalId = setInterval(refreshSettings, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [context?.jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTracks((prev) => {
|
||||
const next = {};
|
||||
for (const t of tracks) {
|
||||
const normalized = normalizePosition(t?.position);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (prev[normalized] !== undefined) {
|
||||
next[normalized] = prev[normalized];
|
||||
} else {
|
||||
next[normalized] = t?.selected !== false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tracks]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
||||
setTrackFields((prev) => {
|
||||
const next = {};
|
||||
for (const t of tracks) {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
const previous = prev[position] || {};
|
||||
const fallbackTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
||||
const fallbackArtist = normalizeTrackText(t?.artist) || defaultArtist;
|
||||
next[position] = {
|
||||
title: normalizeTrackText(previous.title) || fallbackTitle,
|
||||
artist: normalizeTrackText(previous.artist) || fallbackArtist
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tracks, selectedMeta?.artist]);
|
||||
|
||||
const handleFormatOptionChange = (key, value) => {
|
||||
setFormatOptions((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -98,17 +332,73 @@ export default function CdRipConfigPanel({
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const allSelected = tracks.every((t) => selectedTracks[t.position] !== false);
|
||||
const allSelected = tracks.every((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
});
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = !allSelected;
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
map[position] = !allSelected;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
};
|
||||
|
||||
const handleTrackFieldChange = (position, key, value) => {
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
setTrackFields((prev) => ({
|
||||
...prev,
|
||||
[position]: {
|
||||
...(prev[position] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMetaFieldChange = (key, value) => {
|
||||
setMetaFields((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
const selected = tracks
|
||||
.filter((t) => selectedTracks[t.position] !== false)
|
||||
const albumTitle = normalizeTrackText(metaFields?.title)
|
||||
|| normalizeTrackText(selectedMeta?.title)
|
||||
|| normalizeTrackText(context?.detectedTitle)
|
||||
|| 'Audio CD';
|
||||
const albumArtist = normalizeTrackText(metaFields?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist)
|
||||
|| null;
|
||||
const albumYear = normalizeYear(metaFields?.year);
|
||||
|
||||
const normalizedTracks = tracks
|
||||
.map((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
const baseTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
||||
const baseArtist = normalizeTrackText(t?.artist) || normalizeTrackText(selectedMeta?.artist);
|
||||
const edited = trackFields[position] || {};
|
||||
const title = normalizeTrackText(edited.title) || baseTitle;
|
||||
const artist = normalizeTrackText(edited.artist) || baseArtist || null;
|
||||
return {
|
||||
position,
|
||||
title,
|
||||
artist,
|
||||
selected: selectedTracks[position] !== false
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const selected = normalizedTracks
|
||||
.filter((t) => t.selected)
|
||||
.map((t) => t.position);
|
||||
|
||||
if (selected.length === 0) {
|
||||
@@ -118,14 +408,74 @@ export default function CdRipConfigPanel({
|
||||
onStart && onStart({
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selected
|
||||
selectedTracks: selected,
|
||||
tracks: normalizedTracks,
|
||||
metadata: {
|
||||
title: albumTitle,
|
||||
artist: albumArtist,
|
||||
year: albumYear
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const schema = CD_FORMAT_SCHEMAS[format] || { fields: [] };
|
||||
const visibleFields = schema.fields.filter((f) => isFieldVisible(f, formatOptions));
|
||||
|
||||
const selectedCount = tracks.filter((t) => selectedTracks[t.position] !== false).length;
|
||||
const selectedCount = tracks.filter((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
}).length;
|
||||
const firstSelectedTrack = tracks.find((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
}) || null;
|
||||
const devicePath = String(context?.devicePath || context?.device?.path || '').trim();
|
||||
const cdparanoiaCmd = settingsCdparanoiaCmd
|
||||
|| String(context?.cdparanoiaCmd || '').trim()
|
||||
|| 'cdparanoia';
|
||||
const rawWavDir = String(context?.rawWavDir || '').trim();
|
||||
const commandTrackNumber = firstSelectedTrack ? Math.trunc(Number(firstSelectedTrack.position)) : null;
|
||||
const commandWavTarget = commandTrackNumber
|
||||
? (
|
||||
rawWavDir
|
||||
? `${rawWavDir}/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
|
||||
: `<temp>/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
|
||||
)
|
||||
: '<temp>/trackNN.cdda.wav';
|
||||
const cdparanoiaCommandPreview = [
|
||||
quoteShellArg(cdparanoiaCmd),
|
||||
'-d',
|
||||
quoteShellArg(devicePath || '<device>'),
|
||||
String(commandTrackNumber || '<trackNr>'),
|
||||
quoteShellArg(commandWavTarget)
|
||||
].join(' ');
|
||||
const commandTrackNo = commandTrackNumber || 1;
|
||||
const commandTrackFields = trackFields[commandTrackNo] || {};
|
||||
const commandTrackTitle = normalizeTrackText(commandTrackFields.title)
|
||||
|| normalizeTrackText(firstSelectedTrack?.title)
|
||||
|| `Track ${commandTrackNo}`;
|
||||
const commandTrackArtist = normalizeTrackText(commandTrackFields.artist)
|
||||
|| normalizeTrackText(firstSelectedTrack?.artist)
|
||||
|| normalizeTrackText(metaFields?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist)
|
||||
|| 'Unknown Artist';
|
||||
const commandAlbumTitle = normalizeTrackText(metaFields?.title)
|
||||
|| normalizeTrackText(selectedMeta?.title)
|
||||
|| normalizeTrackText(context?.detectedTitle)
|
||||
|| 'Audio CD';
|
||||
const commandYear = normalizeYear(metaFields?.year) ?? normalizeYear(selectedMeta?.year);
|
||||
const commandOutputFile = `<output>/track${String(commandTrackNo).padStart(2, '0')}.${format}`;
|
||||
const encodeCommandPreview = buildEncodeCommandPreview({
|
||||
format,
|
||||
formatOptions,
|
||||
wavFile: commandWavTarget,
|
||||
outFile: commandOutputFile,
|
||||
trackTitle: commandTrackTitle,
|
||||
trackArtist: commandTrackArtist,
|
||||
albumTitle: commandAlbumTitle,
|
||||
year: commandYear,
|
||||
trackNo: commandTrackNo
|
||||
});
|
||||
const progress = Number(pipeline?.progress ?? 0);
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
const eta = String(pipeline?.eta || '').trim();
|
||||
@@ -140,6 +490,8 @@ export default function CdRipConfigPanel({
|
||||
severity={isFinished ? 'success' : 'info'}
|
||||
/>
|
||||
{statusText ? <small>{statusText}</small> : null}
|
||||
<small>1) {cdparanoiaCommandPreview}</small>
|
||||
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
|
||||
{!isFinished ? (
|
||||
<>
|
||||
<ProgressBar value={clampedProgress} />
|
||||
@@ -165,12 +517,32 @@ export default function CdRipConfigPanel({
|
||||
<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}
|
||||
<strong>Album-Metadaten</strong>
|
||||
<div className="metadata-grid" style={{ marginTop: '0.55rem' }}>
|
||||
<InputText
|
||||
value={metaFields.title}
|
||||
onChange={(e) => handleMetaFieldChange('title', e.target.value)}
|
||||
placeholder="Album"
|
||||
disabled={busy}
|
||||
/>
|
||||
<InputText
|
||||
value={metaFields.artist}
|
||||
onChange={(e) => handleMetaFieldChange('artist', e.target.value)}
|
||||
placeholder="Interpret"
|
||||
disabled={busy}
|
||||
/>
|
||||
<InputNumber
|
||||
value={metaFields.year}
|
||||
onValueChange={(e) => handleMetaFieldChange('year', normalizeYear(e.value))}
|
||||
placeholder="Jahr"
|
||||
useGrouping={false}
|
||||
min={1900}
|
||||
max={2100}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Format selection */}
|
||||
<div className="cd-format-field">
|
||||
@@ -210,30 +582,74 @@ export default function CdRipConfigPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="cd-track-list">
|
||||
<table className="cd-track-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="check">Auswahl</th>
|
||||
<th className="num">Nr</th>
|
||||
<th className="artist">Interpret</th>
|
||||
<th className="title">Titel</th>
|
||||
<th className="duration">Länge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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')}` : '-';
|
||||
const position = normalizePosition(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = selectedTracks[position] !== false;
|
||||
const fields = trackFields[position] || {};
|
||||
const titleValue = normalizeTrackText(fields.title)
|
||||
|| normalizeTrackText(track?.title)
|
||||
|| `Track ${position}`;
|
||||
const artistValue = normalizeTrackText(fields.artist)
|
||||
|| normalizeTrackText(track?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist);
|
||||
const duration = formatTrackDuration(track);
|
||||
return (
|
||||
<button
|
||||
key={track.position}
|
||||
type="button"
|
||||
className={`cd-track-row selectable${isSelected ? ' selected' : ''}`}
|
||||
onClick={() => !busy && handleToggleTrack(track.position)}
|
||||
<tr key={position} className={isSelected ? 'selected' : ''}>
|
||||
<td className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggleTrack(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>
|
||||
/>
|
||||
</td>
|
||||
<td className="num">{String(position).padStart(2, '0')}</td>
|
||||
<td className="artist">
|
||||
<InputText
|
||||
value={artistValue}
|
||||
onChange={(e) => handleTrackFieldChange(position, 'artist', e.target.value)}
|
||||
placeholder="Interpret"
|
||||
disabled={busy || !isSelected}
|
||||
/>
|
||||
</td>
|
||||
<td className="title">
|
||||
<InputText
|
||||
value={titleValue}
|
||||
onChange={(e) => handleTrackFieldChange(position, 'title', e.target.value)}
|
||||
placeholder={`Track ${position}`}
|
||||
disabled={busy || !isSelected}
|
||||
/>
|
||||
</td>
|
||||
<td className="duration">{duration}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="cd-format-field">
|
||||
<label>Prompt-/Befehlskette (Preview)</label>
|
||||
<small>1) {cdparanoiaCommandPreview}</small>
|
||||
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
<Button
|
||||
|
||||
@@ -423,7 +423,48 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
);
|
||||
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||
const analyzeContext = getAnalyzeContext(job);
|
||||
const cdTracks = Array.isArray(makemkvInfo?.tracks)
|
||||
? makemkvInfo.tracks
|
||||
.map((track) => {
|
||||
const position = Number(track?.position);
|
||||
if (!Number.isFinite(position) || position <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...track,
|
||||
position: Math.trunc(position),
|
||||
selected: track?.selected !== false
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const devicePath = String(job?.disc_device || '').trim() || null;
|
||||
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
|
||||
? Number(encodePlan.selectedTracks[0])
|
||||
: null;
|
||||
const fallbackTrack = cdTracks[0]?.position ? Number(cdTracks[0].position) : null;
|
||||
const previewTrackPos = Number.isFinite(firstConfiguredTrack) && firstConfiguredTrack > 0
|
||||
? Math.trunc(firstConfiguredTrack)
|
||||
: (Number.isFinite(fallbackTrack) && fallbackTrack > 0 ? Math.trunc(fallbackTrack) : null);
|
||||
const previewWavPath = previewTrackPos && job?.raw_path
|
||||
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
||||
: '<temp>/trackNN.cdda.wav';
|
||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
||||
const selectedMetadata = {
|
||||
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||
artist: cdSelectedMeta?.artist || null,
|
||||
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
||||
mbId: cdSelectedMeta?.mbId || null,
|
||||
coverUrl: cdSelectedMeta?.coverUrl || null,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
|
||||
};
|
||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||
const inputPath = isPreRip
|
||||
@@ -468,17 +509,17 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
jobId,
|
||||
rawPath: job?.raw_path || null,
|
||||
detectedTitle: job?.detected_title || null,
|
||||
mediaProfile: resolveMediaType(job),
|
||||
devicePath,
|
||||
cdparanoiaCmd,
|
||||
cdparanoiaCommandPreview,
|
||||
tracks: cdTracks,
|
||||
inputPath,
|
||||
hasEncodableTitle,
|
||||
reviewConfirmed,
|
||||
mode,
|
||||
sourceJobId: encodePlan?.sourceJobId || null,
|
||||
selectedMetadata: {
|
||||
title: job?.title || job?.detected_title || null,
|
||||
year: job?.year || null,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || null
|
||||
},
|
||||
selectedMetadata,
|
||||
mediaInfoReview: encodePlan,
|
||||
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
||||
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
||||
@@ -502,6 +543,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
...computedContext,
|
||||
...existingContext,
|
||||
rawPath: existingContext.rawPath || computedContext.rawPath,
|
||||
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||
? existingContext.tracks
|
||||
: computedContext.tracks,
|
||||
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
||||
canRestartEncodeFromLastSettings:
|
||||
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
||||
@@ -1353,6 +1397,16 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
||||
try {
|
||||
const response = await api.getMusicBrainzRelease(mbId);
|
||||
return response?.release || null;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCdMetadataSubmit = async (payload) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -2271,6 +2325,7 @@ export default function DashboardPage({
|
||||
}}
|
||||
onSubmit={handleCdMetadataSubmit}
|
||||
onSearch={handleMusicBrainzSearch}
|
||||
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||
busy={busy}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1095,6 +1095,101 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cd-rip-config-panel {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.cd-rip-status {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.cd-meta-summary {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cd-format-field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cd-format-field label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cd-format-field small {
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.cd-track-selection {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.cd-track-list {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cd-track-table {
|
||||
width: 100%;
|
||||
min-width: 44rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.cd-track-table th,
|
||||
.cd-track-table td {
|
||||
border-bottom: 1px solid var(--rip-border);
|
||||
padding: 0.45rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cd-track-table thead th {
|
||||
color: var(--rip-muted);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cd-track-table tbody tr.selected {
|
||||
background: rgba(175, 114, 7, 0.09);
|
||||
}
|
||||
|
||||
.cd-track-table td.check,
|
||||
.cd-track-table th.check {
|
||||
width: 4.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cd-track-table td.num,
|
||||
.cd-track-table th.num {
|
||||
width: 4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cd-track-table td.duration,
|
||||
.cd-track-table th.duration {
|
||||
width: 5.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cd-track-table td.artist .p-inputtext,
|
||||
.cd-track-table td.title .p-inputtext {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2220,6 +2315,10 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cd-track-table {
|
||||
min-width: 36rem;
|
||||
}
|
||||
|
||||
.script-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -807,6 +807,9 @@ StartLimitBurst=3
|
||||
|
||||
# Umgebung
|
||||
Environment=NODE_ENV=production
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=LC_ALL=C.UTF-8
|
||||
Environment=LANGUAGE=C.UTF-8
|
||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -570,6 +570,9 @@ StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
Environment=NODE_ENV=production
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=LC_ALL=C.UTF-8
|
||||
Environment=LANGUAGE=C.UTF-8
|
||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||
|
||||
StandardOutput=journal
|
||||
|
||||
Reference in New Issue
Block a user