Files
ripster/backend/src/services/cdRipService.js
T
2026-03-30 14:37:23 +00:00

785 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};