785 lines
26 KiB
JavaScript
785 lines
26 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const { execFile } = require('child_process');
|
||
const { promisify } = require('util');
|
||
const logger = require('./logger').child('CD_RIP');
|
||
const { spawnTrackedProcess } = require('./processRunner');
|
||
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
|
||
const { ensureDir, transliterateForFilename } = require('../utils/files');
|
||
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 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 = [];
|
||
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||
const tocTablePattern =
|
||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||
|
||
for (const line of lines) {
|
||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||
if (trackMatch) {
|
||
const position = Number(trackMatch[1]);
|
||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||
.replace(tocTimePattern, ' ');
|
||
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;
|
||
}
|
||
|
||
// 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(tocTablePattern);
|
||
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;
|
||
}
|
||
|
||
async function readToc(devicePath, cmd) {
|
||
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
|
||
logger.info('toc:read', { devicePath, cmd: cdparanoia });
|
||
try {
|
||
// 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 || ''}\n${stdout || ''}`);
|
||
logger.info('toc:done', { devicePath, trackCount: tracks.length });
|
||
return tracks;
|
||
} catch (error) {
|
||
// cdparanoia -Q may exit non-zero even when TOC is readable.
|
||
const stderr = String(error?.stderr || '');
|
||
const stdout = String(error?.stdout || '');
|
||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||
if (tracks.length > 0) {
|
||
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
|
||
return tracks;
|
||
}
|
||
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
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 sanitizePathSegment(value, fallback = 'unknown') {
|
||
const raw = transliterateForFilename(String(value == null ? '' : value))
|
||
.replace(/[\\/:*?"<>|]/g, '-')
|
||
.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++) {
|
||
const outputPart = outputSegments[offset + i];
|
||
const relativePart = relativeSegments[i];
|
||
if (outputPart === relativePart) {
|
||
continue;
|
||
}
|
||
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
|
||
// Treat this as containing the same relative directory to avoid nesting:
|
||
// Album (2007)_2/Album (2007)/...
|
||
const isLastRelativeSegment = i === relativeSegments.length - 1;
|
||
const matchesNumberedSuffix = isLastRelativeSegment
|
||
&& outputPart.startsWith(`${relativePart}_`)
|
||
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
|
||
if (!matchesNumberedSuffix) {
|
||
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(' ');
|
||
}
|
||
|
||
function copyFilePreservingRaw(sourcePath, targetPath) {
|
||
const rawSource = String(sourcePath || '').trim();
|
||
const rawTarget = String(targetPath || '').trim();
|
||
if (!rawSource || !rawTarget) {
|
||
return;
|
||
}
|
||
const source = path.resolve(rawSource);
|
||
const target = path.resolve(rawTarget);
|
||
if (source === target) {
|
||
return;
|
||
}
|
||
fs.copyFileSync(source, target);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
|
||
* Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
|
||
*/
|
||
function getSortedPlainWavFiles(rawWavDir) {
|
||
try {
|
||
return fs.readdirSync(rawWavDir)
|
||
.filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
|
||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolves the WAV file path for a given track position.
|
||
* Primary: track01.cdda.wav (cdparanoia format)
|
||
* Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
|
||
*/
|
||
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
|
||
const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
|
||
if (fs.existsSync(cddaPath)) return cddaPath;
|
||
if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
|
||
const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
|
||
if (fs.existsSync(fallback)) return fallback;
|
||
}
|
||
return cddaPath;
|
||
}
|
||
|
||
/**
|
||
* Rip and encode a CD.
|
||
*
|
||
* @param {object} options
|
||
* @param {string} options.jobId - Job ID for logging
|
||
* @param {string} options.devicePath - e.g. /dev/sr0
|
||
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
|
||
* @param {string} options.rawWavDir - temp dir for WAV files
|
||
* @param {string} options.outputDir - final output dir
|
||
* @param {string} options.format - wav|flac|mp3|opus|ogg
|
||
* @param {object} options.formatOptions - encoder-specific options
|
||
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
|
||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||
* @param {object} options.meta - album metadata {title, artist, year}
|
||
* @param {string} options.outputTemplate - template for relative output path without extension
|
||
* @param {boolean} options.skipEncode - true => rip only (no encode)
|
||
* @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) {
|
||
const {
|
||
jobId,
|
||
devicePath,
|
||
cdparanoiaCmd = 'cdparanoia',
|
||
rawWavDir,
|
||
outputDir,
|
||
format = 'flac',
|
||
formatOptions = {},
|
||
selectedTracks = [],
|
||
tracks = [],
|
||
meta = {},
|
||
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
|
||
onProgress,
|
||
onLog,
|
||
onProcessHandle,
|
||
isCancelled,
|
||
context,
|
||
skipRip = false,
|
||
skipEncode = false
|
||
} = options;
|
||
|
||
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
|
||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||
}
|
||
|
||
const tracksToRip = selectedTracks.length > 0
|
||
? tracks.filter((t) => selectedTracks.includes(t.position))
|
||
: tracks;
|
||
|
||
if (tracksToRip.length === 0) {
|
||
throw new Error('Keine Tracks zum Rippen ausgewählt.');
|
||
}
|
||
|
||
await ensureDir(rawWavDir);
|
||
if (!skipEncode) {
|
||
await ensureDir(outputDir);
|
||
}
|
||
|
||
logger.info('rip:start', {
|
||
jobId,
|
||
devicePath,
|
||
format,
|
||
trackCount: tracksToRip.length
|
||
});
|
||
|
||
const log = (level, msg) => {
|
||
logger[level] && logger[level](msg, { jobId });
|
||
onLog && onLog(level, msg);
|
||
};
|
||
const ripPercentSpan = skipEncode ? 100 : 50;
|
||
const encodePercentStart = ripPercentSpan;
|
||
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||
|
||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
|
||
|
||
if (skipRip) {
|
||
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||
for (const track of tracksToRip) {
|
||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||
if (!fs.existsSync(wavFile)) {
|
||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||
}
|
||
}
|
||
} else {
|
||
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];
|
||
|
||
onProgress && onProgress({
|
||
phase: 'rip',
|
||
trackEvent: 'start',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 0,
|
||
percent: (i / tracksToRip.length) * ripPercentSpan
|
||
});
|
||
|
||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
||
|
||
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) * ripPercentSpan;
|
||
onProgress && onProgress({
|
||
phase: 'rip',
|
||
trackEvent: 'progress',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: parsed.percent,
|
||
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 ${normalizeExitCode(error)})`
|
||
);
|
||
}
|
||
|
||
onProgress && onProgress({
|
||
phase: 'rip',
|
||
trackEvent: 'complete',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 100,
|
||
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
|
||
});
|
||
|
||
log('info', `Track ${track.position} gerippt.`);
|
||
}
|
||
} // end if (!skipRip)
|
||
|
||
if (skipEncode) {
|
||
return {
|
||
outputDir: null,
|
||
format: 'raw',
|
||
trackCount: tracksToRip.length,
|
||
encodeResults: []
|
||
};
|
||
}
|
||
|
||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||
const encodeResults = [];
|
||
|
||
if (format === 'wav') {
|
||
// Keep RAW WAVs in place and copy them to the final output structure.
|
||
for (let i = 0; i < tracksToRip.length; i++) {
|
||
assertNotCancelled(isCancelled);
|
||
const track = tracksToRip[i];
|
||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||
onProgress && onProgress({
|
||
phase: 'encode',
|
||
trackEvent: 'start',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 0,
|
||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||
});
|
||
ensureDir(path.dirname(outFile));
|
||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||
copyFilePreservingRaw(wavFile, outFile);
|
||
onProgress && onProgress({
|
||
phase: 'encode',
|
||
trackEvent: 'complete',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 100,
|
||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||
});
|
||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||
}
|
||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||
}
|
||
|
||
for (let i = 0; i < tracksToRip.length; i++) {
|
||
assertNotCancelled(isCancelled);
|
||
const track = tracksToRip[i];
|
||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||
|
||
if (!fs.existsSync(wavFile)) {
|
||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||
}
|
||
|
||
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
||
ensureDir(path.dirname(outFile));
|
||
|
||
onProgress && onProgress({
|
||
phase: 'encode',
|
||
trackEvent: 'start',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 0,
|
||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||
});
|
||
|
||
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)}`);
|
||
|
||
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;
|
||
}
|
||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
|
||
throw new Error(
|
||
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||
);
|
||
}
|
||
|
||
// Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
|
||
// the source WAV. Restore it from the encoded output so the RAW folder stays filled.
|
||
if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
|
||
try {
|
||
fs.copyFileSync(outFile, wavFile);
|
||
log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`);
|
||
} catch (restoreErr) {
|
||
log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
|
||
}
|
||
}
|
||
|
||
onProgress && onProgress({
|
||
phase: 'encode',
|
||
trackEvent: 'complete',
|
||
trackIndex: i + 1,
|
||
trackTotal: tracksToRip.length,
|
||
trackPosition: track.position,
|
||
trackPercent: 100,
|
||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||
});
|
||
|
||
log('info', `Track ${track.position} encodiert.`);
|
||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
|
||
}
|
||
|
||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||
}
|
||
|
||
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||
const artist = track?.artist || meta?.artist || '';
|
||
const album = meta?.title || '';
|
||
const year = meta?.year ? String(meta.year) : '';
|
||
const trackTitle = track.title || `Track ${track.position}`;
|
||
const trackNum = String(track.position);
|
||
|
||
if (format === 'flac') {
|
||
const level = Number(opts.flacCompression ?? 5);
|
||
const clampedLevel = Math.max(0, Math.min(8, level));
|
||
return {
|
||
cmd: 'flac',
|
||
args: [
|
||
`--compression-level-${clampedLevel}`,
|
||
'--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
|
||
'--tag', `TITLE=${trackTitle}`,
|
||
'--tag', `ARTIST=${artist}`,
|
||
'--tag', `ALBUM=${album}`,
|
||
'--tag', `DATE=${year}`,
|
||
'--tag', `TRACKNUMBER=${trackNum}`,
|
||
wavFile,
|
||
'-o', outFile
|
||
]
|
||
};
|
||
}
|
||
|
||
if (format === 'mp3') {
|
||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||
const args = ['--id3v2-only', '--noreplaygain'];
|
||
if (mode === 'vbr') {
|
||
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
|
||
args.push('-V', String(quality));
|
||
} else {
|
||
const bitrate = Number(opts.mp3Bitrate ?? 192);
|
||
args.push('-b', String(bitrate));
|
||
}
|
||
args.push(
|
||
'--tt', trackTitle,
|
||
'--ta', artist,
|
||
'--tl', album,
|
||
'--ty', year,
|
||
'--tn', trackNum,
|
||
wavFile,
|
||
outFile
|
||
);
|
||
return { cmd: 'lame', args };
|
||
}
|
||
|
||
if (format === 'opus') {
|
||
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
|
||
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
|
||
return {
|
||
cmd: 'opusenc',
|
||
args: [
|
||
'--bitrate', String(bitrate),
|
||
'--comp', String(complexity),
|
||
'--title', trackTitle,
|
||
'--artist', artist,
|
||
'--album', album,
|
||
'--date', year,
|
||
'--tracknumber', trackNum,
|
||
wavFile,
|
||
outFile
|
||
]
|
||
};
|
||
}
|
||
|
||
if (format === 'ogg') {
|
||
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
|
||
return {
|
||
cmd: 'oggenc',
|
||
args: [
|
||
'-q', String(quality),
|
||
'-t', trackTitle,
|
||
'-a', artist,
|
||
'-l', album,
|
||
'-d', year,
|
||
'-N', trackNum,
|
||
'-o', outFile,
|
||
wavFile
|
||
]
|
||
};
|
||
}
|
||
|
||
throw new Error(`Unbekanntes Format: ${format}`);
|
||
}
|
||
|
||
module.exports = {
|
||
parseToc,
|
||
readToc,
|
||
ripAndEncode,
|
||
buildOutputDir,
|
||
buildOutputFilename,
|
||
DEFAULT_CD_OUTPUT_TEMPLATE,
|
||
SUPPORTED_FORMATS
|
||
};
|