First test
This commit is contained in:
@@ -46,6 +46,50 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchMusicBrainz(String(query));
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cd/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body;
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId });
|
||||
const job = await pipelineService.selectCdMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
mbId,
|
||||
coverUrl,
|
||||
tracks
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cd/start/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const ripConfig = req.body || {};
|
||||
logger.info('post:cd:start', { reqId: req.reqId, jobId, format: ripConfig.format });
|
||||
const result = await pipelineService.startCdRip(jobId, ripConfig);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
353
backend/src/services/cdRipService.js
Normal file
353
backend/src/services/cdRipService.js
Normal file
@@ -0,0 +1,353 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('CD_RIP');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
|
||||
|
||||
/**
|
||||
* Parse cdparanoia -Q output (stderr) to extract track information.
|
||||
* Example output:
|
||||
* Table of contents (start sector, length [start sector, length]):
|
||||
* track 1: 0 (00:00.00) 24218 (05:22.43)
|
||||
* track 2: 24218 (05:22.43) 15120 (03:21.20)
|
||||
* TOTAL 193984 (43:04.59)
|
||||
*/
|
||||
function parseToc(tocOutput) {
|
||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||
const tracks = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*track\s+(\d+)\s*:\s*(\d+)\s+\((\d+):(\d+)\.(\d+)\)\s+(\d+)\s+\((\d+):(\d+)\.(\d+)\)/i);
|
||||
if (!m) {
|
||||
continue;
|
||||
}
|
||||
const startSector = Number(m[2]);
|
||||
const lengthSector = Number(m[6]);
|
||||
// duration in seconds: sectors / 75
|
||||
const durationSec = Math.round(lengthSector / 75);
|
||||
tracks.push({
|
||||
position: Number(m[1]),
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
async function readToc(devicePath, cmd) {
|
||||
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||
logger.info('toc:read', { devicePath, cmd: cdparanoia });
|
||||
try {
|
||||
// cdparanoia -Q writes to stderr, exits 0 on success
|
||||
const { stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
|
||||
timeout: 15000
|
||||
});
|
||||
const tracks = parseToc(stderr);
|
||||
logger.info('toc:done', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
} catch (error) {
|
||||
// cdparanoia -Q exits non-zero sometimes even on success; try parsing stderr
|
||||
const stderr = String(error?.stderr || '');
|
||||
const tracks = parseToc(stderr);
|
||||
if (tracks.length > 0) {
|
||||
logger.info('toc:done-from-stderr', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
}
|
||||
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputFilename(track, meta, format) {
|
||||
const ext = format === 'wav' ? 'wav' : format;
|
||||
const num = String(track.position).padStart(2, '0');
|
||||
const trackTitle = (track.title || `Track ${track.position}`)
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.trim();
|
||||
return `${num}. ${trackTitle}.${ext}`;
|
||||
}
|
||||
|
||||
function buildOutputDir(meta, baseDir) {
|
||||
const artist = (meta?.artist || 'Unknown Artist').replace(/[/\\?%*:|"<>]/g, '-').trim();
|
||||
const album = (meta?.title || 'Unknown Album').replace(/[/\\?%*:|"<>]/g, '-').trim();
|
||||
const year = meta?.year ? ` (${meta.year})` : '';
|
||||
const folderName = `${artist} - ${album}${year}`;
|
||||
return path.join(baseDir, folderName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip and encode a CD.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.jobId - Job ID for logging
|
||||
* @param {string} options.devicePath - e.g. /dev/sr0
|
||||
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
|
||||
* @param {string} options.rawWavDir - temp dir for WAV files
|
||||
* @param {string} options.outputDir - final output dir
|
||||
* @param {string} options.format - wav|flac|mp3|opus|ogg
|
||||
* @param {object} options.formatOptions - encoder-specific options
|
||||
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
|
||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||
* @param {object} options.meta - album metadata {title, artist, year}
|
||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||
* @param {Function} options.onLog - (level, msg) => void
|
||||
* @param {object} options.context - passed to spawnTrackedProcess
|
||||
*/
|
||||
async function ripAndEncode(options) {
|
||||
const {
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd = 'cdparanoia',
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format = 'flac',
|
||||
formatOptions = {},
|
||||
selectedTracks = [],
|
||||
tracks = [],
|
||||
meta = {},
|
||||
onProgress,
|
||||
onLog,
|
||||
context
|
||||
} = options;
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(format)) {
|
||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||
}
|
||||
|
||||
const tracksToRip = selectedTracks.length > 0
|
||||
? tracks.filter((t) => selectedTracks.includes(t.position))
|
||||
: tracks;
|
||||
|
||||
if (tracksToRip.length === 0) {
|
||||
throw new Error('Keine Tracks zum Rippen ausgewählt.');
|
||||
}
|
||||
|
||||
await ensureDir(rawWavDir);
|
||||
await ensureDir(outputDir);
|
||||
|
||||
logger.info('rip:start', {
|
||||
jobId,
|
||||
devicePath,
|
||||
format,
|
||||
trackCount: tracksToRip.length
|
||||
});
|
||||
|
||||
const log = (level, msg) => {
|
||||
logger[level] && logger[level](msg, { jobId });
|
||||
onLog && onLog(level, msg);
|
||||
};
|
||||
|
||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
|
||||
log('info', `Rippe Track ${track.position} von ${tracks.length} …`);
|
||||
|
||||
let lastTrackPercent = 0;
|
||||
|
||||
const runInfo = await spawnTrackedProcess({
|
||||
cmd: cdparanoiaCmd,
|
||||
args: ['-d', devicePath, String(track.position), wavFile],
|
||||
cwd: rawWavDir,
|
||||
onStderrLine(line) {
|
||||
const parsed = parseCdParanoiaProgress(line);
|
||||
if (parsed && parsed.percent !== null) {
|
||||
lastTrackPercent = parsed.percent;
|
||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
percent: overallPercent
|
||||
});
|
||||
}
|
||||
},
|
||||
context
|
||||
});
|
||||
|
||||
if (runInfo.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${runInfo.exitCode})`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
percent: ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} gerippt.`);
|
||||
}
|
||||
|
||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||
if (format === 'wav') {
|
||||
// Just move WAV files to output dir with proper names
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const outFile = path.join(outputDir, buildOutputFilename(track, meta, 'wav'));
|
||||
fs.renameSync(wavFile, outFile);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||
}
|
||||
return { outputDir, format, trackCount: tracksToRip.length };
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||
}
|
||||
|
||||
const outFilename = buildOutputFilename(track, meta, format);
|
||||
const outFile = path.join(outputDir, outFilename);
|
||||
|
||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||
|
||||
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
||||
|
||||
await spawnTrackedProcess({
|
||||
cmd: encodeArgs.cmd,
|
||||
args: encodeArgs.args,
|
||||
cwd: rawWavDir,
|
||||
onStdoutLine() {},
|
||||
onStderrLine() {},
|
||||
context
|
||||
});
|
||||
|
||||
// Clean up WAV after encode
|
||||
try {
|
||||
fs.unlinkSync(wavFile);
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} encodiert.`);
|
||||
}
|
||||
|
||||
return { outputDir, format, trackCount: tracksToRip.length };
|
||||
}
|
||||
|
||||
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||||
const artist = meta?.artist || '';
|
||||
const album = meta?.title || '';
|
||||
const year = meta?.year ? String(meta.year) : '';
|
||||
const trackTitle = track.title || `Track ${track.position}`;
|
||||
const trackNum = String(track.position);
|
||||
|
||||
if (format === 'flac') {
|
||||
const level = Number(opts.flacCompression ?? 5);
|
||||
const clampedLevel = Math.max(0, Math.min(8, level));
|
||||
return {
|
||||
cmd: 'flac',
|
||||
args: [
|
||||
`--compression-level-${clampedLevel}`,
|
||||
'--tag', `TITLE=${trackTitle}`,
|
||||
'--tag', `ARTIST=${artist}`,
|
||||
'--tag', `ALBUM=${album}`,
|
||||
'--tag', `DATE=${year}`,
|
||||
'--tag', `TRACKNUMBER=${trackNum}`,
|
||||
wavFile,
|
||||
'-o', outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'mp3') {
|
||||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
const args = ['--id3v2-only', '--noreplaygain'];
|
||||
if (mode === 'vbr') {
|
||||
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
|
||||
args.push('-V', String(quality));
|
||||
} else {
|
||||
const bitrate = Number(opts.mp3Bitrate ?? 192);
|
||||
args.push('-b', String(bitrate));
|
||||
}
|
||||
args.push(
|
||||
'--tt', trackTitle,
|
||||
'--ta', artist,
|
||||
'--tl', album,
|
||||
'--ty', year,
|
||||
'--tn', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
);
|
||||
return { cmd: 'lame', args };
|
||||
}
|
||||
|
||||
if (format === 'opus') {
|
||||
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
|
||||
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
|
||||
return {
|
||||
cmd: 'opusenc',
|
||||
args: [
|
||||
'--bitrate', String(bitrate),
|
||||
'--comp', String(complexity),
|
||||
'--title', trackTitle,
|
||||
'--artist', artist,
|
||||
'--album', album,
|
||||
'--date', year,
|
||||
'--tracknumber', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'ogg') {
|
||||
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
|
||||
return {
|
||||
cmd: 'oggenc',
|
||||
args: [
|
||||
'-q', String(quality),
|
||||
'-t', trackTitle,
|
||||
'-a', artist,
|
||||
'-l', album,
|
||||
'-d', year,
|
||||
'-N', trackNum,
|
||||
'-o', outFile,
|
||||
wavFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unbekanntes Format: ${format}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readToc,
|
||||
ripAndEncode,
|
||||
buildOutputDir,
|
||||
SUPPORTED_FORMATS
|
||||
};
|
||||
@@ -52,14 +52,17 @@ function normalizeMediaProfile(rawValue) {
|
||||
) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
||||
if (value === 'cd' || value === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (value === 'disc' || value === 'other' || value === 'sonstiges') {
|
||||
return 'other';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSpecificMediaProfile(value) {
|
||||
return value === 'bluray' || value === 'dvd';
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||
}
|
||||
|
||||
function inferMediaProfileFromTextParts(parts) {
|
||||
@@ -82,6 +85,9 @@ function inferMediaProfileFromTextParts(parts) {
|
||||
|
||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||
if (fstype === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
const model = String(rawModel || '').trim().toLowerCase();
|
||||
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
||||
const hasDvdModelMarker = /dvd/.test(model);
|
||||
@@ -142,7 +148,7 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||
return 'other';
|
||||
return 'cd';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -493,22 +499,48 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
async checkMediaPresent(devicePath) {
|
||||
let blkidType = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||
const type = String(stdout || '').trim().toLowerCase();
|
||||
const has = type.length > 0;
|
||||
logger.debug('blkid:result', { devicePath, hasMedia: has, type });
|
||||
return {
|
||||
hasMedia: has,
|
||||
type: type || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) });
|
||||
return {
|
||||
hasMedia: false,
|
||||
type: null
|
||||
};
|
||||
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||
} catch (_error) {
|
||||
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||
}
|
||||
|
||||
if (blkidType) {
|
||||
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
||||
return { hasMedia: true, type: blkidType };
|
||||
}
|
||||
|
||||
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||
try {
|
||||
const { stdout } = await execFileAsync('udevadm', [
|
||||
'info',
|
||||
'--query=property',
|
||||
'--name',
|
||||
devicePath
|
||||
]);
|
||||
const props = {};
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
|
||||
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
logger.debug('udevadm:audio-cd', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
} catch (_udevError) {
|
||||
// udevadm not available or failed – ignore
|
||||
}
|
||||
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||
return { hasMedia: false, type: null };
|
||||
}
|
||||
|
||||
async getDiscLabel(devicePath) {
|
||||
@@ -560,6 +592,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
async inferMediaProfile(devicePath, hints = {}) {
|
||||
// Audio CDs have no filesystem – short-circuit immediately
|
||||
if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||
if (isSpecificMediaProfile(explicit)) {
|
||||
return explicit;
|
||||
|
||||
142
backend/src/services/musicBrainzService.js
Normal file
142
backend/src/services/musicBrainzService.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('MUSICBRAINZ');
|
||||
|
||||
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
|
||||
const MB_TIMEOUT_MS = 10000;
|
||||
|
||||
async function mbFetch(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': MB_USER_AGENT
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelease(release) {
|
||||
if (!release) {
|
||||
return null;
|
||||
}
|
||||
const artistCredit = Array.isArray(release['artist-credit'])
|
||||
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
|
||||
: null;
|
||||
const date = String(release.date || '').trim();
|
||||
const yearMatch = date.match(/\b(\d{4})\b/);
|
||||
const year = yearMatch ? Number(yearMatch[1]) : null;
|
||||
|
||||
const media = Array.isArray(release.media) ? release.media : [];
|
||||
const tracks = media.flatMap((medium, mediumIdx) => {
|
||||
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
|
||||
return mediumTracks.map((track) => ({
|
||||
position: Number(track.position || mediumIdx * 100 + 1),
|
||||
number: String(track.number || track.position || ''),
|
||||
title: String(track.title || ''),
|
||||
durationMs: Number(track.length || 0) || null
|
||||
}));
|
||||
});
|
||||
|
||||
let coverArtUrl = null;
|
||||
if (release['cover-art-archive'] && release['cover-art-archive'].front) {
|
||||
coverArtUrl = `https://coverartarchive.org/release/${release.id}/front-250`;
|
||||
}
|
||||
|
||||
return {
|
||||
mbId: String(release.id || ''),
|
||||
title: String(release.title || ''),
|
||||
artist: artistCredit || null,
|
||||
year,
|
||||
date,
|
||||
country: String(release.country || '').trim() || null,
|
||||
label: Array.isArray(release['label-info'])
|
||||
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
|
||||
: null,
|
||||
coverArtUrl,
|
||||
tracks
|
||||
};
|
||||
}
|
||||
|
||||
class MusicBrainzService {
|
||||
async isEnabled() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return settings.musicbrainz_enabled !== 'false';
|
||||
}
|
||||
|
||||
async searchByTitle(query) {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('search:start', { query: q });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release`);
|
||||
url.searchParams.set('query', q);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('limit', '10');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const releases = Array.isArray(data.releases) ? data.releases : [];
|
||||
const results = releases.map(normalizeRelease).filter(Boolean);
|
||||
logger.info('search:done', { query: q, count: results.length });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchByDiscLabel(discLabel) {
|
||||
return this.searchByTitle(discLabel);
|
||||
}
|
||||
|
||||
async getReleaseById(mbId) {
|
||||
const id = String(mbId || '').trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('getById:start', { mbId: id });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release/${id}`);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings+cover-art-archive');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const result = normalizeRelease(data);
|
||||
logger.info('getById:done', { mbId: id, title: result?.title });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MusicBrainzService();
|
||||
@@ -5,6 +5,8 @@ const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const omdbService = require('./omdbService');
|
||||
const musicBrainzService = require('./musicBrainzService');
|
||||
const cdRipService = require('./cdRipService');
|
||||
const scriptService = require('./scriptService');
|
||||
const scriptChainService = require('./scriptChainService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
@@ -20,7 +22,7 @@ const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/pl
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const userPresetService = require('./userPresetService');
|
||||
|
||||
const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']);
|
||||
const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
|
||||
const REVIEW_REFRESH_SETTING_PREFIXES = [
|
||||
'handbrake_',
|
||||
'mediainfo_',
|
||||
@@ -101,14 +103,17 @@ function normalizeMediaProfile(value) {
|
||||
) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||
if (raw === 'cd' || raw === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges') {
|
||||
return 'other';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSpecificMediaProfile(value) {
|
||||
return value === 'bluray' || value === 'dvd';
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||
}
|
||||
|
||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
@@ -5114,6 +5119,11 @@ class PipelineService extends EventEmitter {
|
||||
mediaProfile
|
||||
};
|
||||
|
||||
// Route audio CDs to the dedicated CD pipeline
|
||||
if (mediaProfile === 'cd') {
|
||||
return this.analyzeCd(deviceWithProfile);
|
||||
}
|
||||
|
||||
const job = await historyService.createJob({
|
||||
discDevice: device.path,
|
||||
status: 'METADATA_SELECTION',
|
||||
@@ -9752,6 +9762,347 @@ class PipelineService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
||||
|
||||
async analyzeCd(device) {
|
||||
const devicePath = String(device?.path || '').trim();
|
||||
const detectedTitle = String(
|
||||
device?.discLabel || device?.label || device?.model || 'Audio CD'
|
||||
).trim();
|
||||
|
||||
logger.info('cd:analyze:start', { devicePath, detectedTitle });
|
||||
|
||||
const job = await historyService.createJob({
|
||||
discDevice: devicePath,
|
||||
status: 'CD_METADATA_SELECTION',
|
||||
detectedTitle
|
||||
});
|
||||
|
||||
try {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
|
||||
// Read TOC
|
||||
await this.setState('CD_ANALYZING', {
|
||||
activeJobId: job.id,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'CD wird analysiert …',
|
||||
context: { jobId: job.id, device, mediaProfile: 'cd' }
|
||||
});
|
||||
|
||||
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
|
||||
logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length });
|
||||
|
||||
// Search MusicBrainz
|
||||
const mbCandidates = await musicBrainzService
|
||||
.searchByDiscLabel(detectedTitle)
|
||||
.catch(() => []);
|
||||
|
||||
const cdInfo = {
|
||||
phase: 'PREPARE',
|
||||
mediaProfile: 'cd',
|
||||
preparedAt: nowIso(),
|
||||
tracks,
|
||||
detectedTitle
|
||||
};
|
||||
|
||||
await historyService.updateJob(job.id, {
|
||||
status: 'CD_METADATA_SELECTION',
|
||||
last_state: 'CD_METADATA_SELECTION',
|
||||
detected_title: detectedTitle,
|
||||
makemkv_info_json: JSON.stringify(cdInfo)
|
||||
});
|
||||
await historyService.appendLog(
|
||||
job.id,
|
||||
'SYSTEM',
|
||||
`CD analysiert: ${tracks.length} Track(s) gefunden. MusicBrainz: ${mbCandidates.length} Treffer.`
|
||||
);
|
||||
|
||||
const runningJobs = await historyService.getRunningJobs();
|
||||
const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id));
|
||||
if (!foreignRunningJobs.length) {
|
||||
await this.setState('CD_METADATA_SELECTION', {
|
||||
activeJobId: job.id,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'CD-Metadaten auswählen',
|
||||
context: {
|
||||
jobId: job.id,
|
||||
device,
|
||||
mediaProfile: 'cd',
|
||||
detectedTitle,
|
||||
tracks,
|
||||
mbCandidates
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { jobId: job.id, detectedTitle, tracks, mbCandidates };
|
||||
} catch (error) {
|
||||
logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) });
|
||||
await this.failJob(job.id, 'CD_ANALYZING', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchMusicBrainz(query) {
|
||||
logger.info('musicbrainz:search', { query });
|
||||
const results = await musicBrainzService.searchByTitle(query);
|
||||
logger.info('musicbrainz:search:done', { query, count: results.length });
|
||||
return results;
|
||||
}
|
||||
|
||||
async selectCdMetadata(payload) {
|
||||
const {
|
||||
jobId,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
mbId,
|
||||
coverUrl,
|
||||
tracks: selectedTracks
|
||||
} = payload || {};
|
||||
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const job = await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('cd:select-metadata', { jobId, title, artist, year, mbId });
|
||||
|
||||
const cdInfo = this.safeParseJson(job.makemkv_info_json) || {};
|
||||
|
||||
// Merge track metadata from selection into existing TOC tracks
|
||||
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||
const mergedTracks = tocTracks.map((t) => {
|
||||
const selected = Array.isArray(selectedTracks)
|
||||
? selectedTracks.find((st) => Number(st.position) === Number(t.position))
|
||||
: null;
|
||||
return {
|
||||
...t,
|
||||
title: selected?.title || t.title || `Track ${t.position}`,
|
||||
selected: selected ? Boolean(selected.selected) : true
|
||||
};
|
||||
});
|
||||
|
||||
const updatedCdInfo = {
|
||||
...cdInfo,
|
||||
tracks: mergedTracks,
|
||||
selectedMetadata: { title, artist, year, mbId, coverUrl }
|
||||
};
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
title: title || null,
|
||||
year: year ? Number(year) : null,
|
||||
poster_url: coverUrl || null,
|
||||
status: 'CD_READY_TO_RIP',
|
||||
last_state: 'CD_READY_TO_RIP',
|
||||
makemkv_info_json: JSON.stringify(updatedCdInfo)
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).`
|
||||
);
|
||||
|
||||
if (this.isPrimaryJob(jobId)) {
|
||||
await this.setState('CD_READY_TO_RIP', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'CD bereit zum Rippen',
|
||||
context: {
|
||||
...(this.snapshot.context || {}),
|
||||
jobId,
|
||||
mediaProfile: 'cd',
|
||||
tracks: mergedTracks,
|
||||
selectedMetadata: { title, artist, year, mbId, coverUrl }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return historyService.getJobById(jobId);
|
||||
}
|
||||
|
||||
async startCdRip(jobId, ripConfig) {
|
||||
this.ensureNotBusy('startCdRip', jobId);
|
||||
|
||||
const job = await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cdInfo = this.safeParseJson(job.makemkv_info_json) || {};
|
||||
const device = this.detectedDisc || this.snapshot.context?.device;
|
||||
const devicePath = String(device?.path || job.disc_device || '').trim();
|
||||
|
||||
if (!devicePath) {
|
||||
const error = new Error('Kein CD-Laufwerk bekannt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const format = String(ripConfig?.format || 'flac').trim().toLowerCase();
|
||||
const formatOptions = ripConfig?.formatOptions || {};
|
||||
const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks)
|
||||
? ripConfig.selectedTracks.map(Number).filter(Number.isFinite)
|
||||
: [];
|
||||
|
||||
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||
const selectedMeta = cdInfo.selectedMetadata || {};
|
||||
|
||||
const settings = await settingsService.getEffectiveSettingsMap('cd');
|
||||
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const rawBaseDir = String(settings.raw_dir || 'data/output/raw').trim();
|
||||
const jobDir = `CD_Job${jobId}_${Date.now()}`;
|
||||
const rawWavDir = path.join(rawBaseDir, jobDir, 'wav');
|
||||
const outputDir = cdRipService.buildOutputDir(selectedMeta, path.join(rawBaseDir, jobDir));
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'CD_RIPPING',
|
||||
last_state: 'CD_RIPPING',
|
||||
error_message: null,
|
||||
raw_path: rawWavDir,
|
||||
output_path: outputDir,
|
||||
encode_plan_json: JSON.stringify({ format, formatOptions, selectedTracks: selectedTrackPositions })
|
||||
});
|
||||
|
||||
await this.setState('CD_RIPPING', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'CD wird gerippt …',
|
||||
context: {
|
||||
...(this.snapshot.context || {}),
|
||||
jobId,
|
||||
mediaProfile: 'cd',
|
||||
selectedMetadata: selectedMeta
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('cd:rip:start', { jobId, devicePath, format, trackCount: selectedTrackPositions.length });
|
||||
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip gestartet: Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}`);
|
||||
|
||||
// Run asynchronously so the HTTP response returns immediately
|
||||
this._runCdRip({
|
||||
job,
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd,
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTrackPositions,
|
||||
tocTracks,
|
||||
selectedMeta
|
||||
}).catch((error) => {
|
||||
logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) });
|
||||
});
|
||||
|
||||
return { jobId, started: true };
|
||||
}
|
||||
|
||||
async _runCdRip({
|
||||
job,
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd,
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTrackPositions,
|
||||
tocTracks,
|
||||
selectedMeta
|
||||
}) {
|
||||
const processKey = Number(jobId);
|
||||
this.activeProcesses.set(processKey, { cancel: () => {} });
|
||||
|
||||
try {
|
||||
await cdRipService.ripAndEncode({
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd,
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selectedTrackPositions,
|
||||
tracks: tocTracks,
|
||||
meta: selectedMeta,
|
||||
onProgress: async ({ phase, percent }) => {
|
||||
const clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
const statusText = phase === 'rip' ? 'CD wird gerippt …' : 'Tracks werden encodiert …';
|
||||
const newState = phase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING';
|
||||
|
||||
if (phase === 'encode' && this.snapshot.state === 'CD_RIPPING') {
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'CD_ENCODING',
|
||||
last_state: 'CD_ENCODING'
|
||||
});
|
||||
}
|
||||
|
||||
await this.setState(newState, {
|
||||
activeJobId: jobId,
|
||||
progress: clampedPercent,
|
||||
eta: null,
|
||||
statusText,
|
||||
context: this.snapshot.context
|
||||
});
|
||||
},
|
||||
onLog: async (level, msg) => {
|
||||
await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
|
||||
},
|
||||
context: { jobId: processKey }
|
||||
});
|
||||
|
||||
// Success
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
end_time: nowIso(),
|
||||
rip_successful: 1,
|
||||
output_path: outputDir
|
||||
});
|
||||
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`);
|
||||
|
||||
await this.setState('FINISHED', {
|
||||
activeJobId: jobId,
|
||||
progress: 100,
|
||||
eta: null,
|
||||
statusText: 'CD-Rip abgeschlossen',
|
||||
context: {
|
||||
jobId,
|
||||
mediaProfile: 'cd',
|
||||
outputDir,
|
||||
selectedMetadata: selectedMeta
|
||||
}
|
||||
});
|
||||
|
||||
void this.notifyPushover('job_finished', {
|
||||
title: 'Ripster - CD Rip erfolgreich',
|
||||
message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) });
|
||||
await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error);
|
||||
} finally {
|
||||
this.activeProcesses.delete(processKey);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new PipelineService();
|
||||
|
||||
@@ -36,17 +36,19 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other', 'cd'];
|
||||
const PROFILED_SETTINGS = {
|
||||
raw_dir: {
|
||||
bluray: 'raw_dir_bluray',
|
||||
dvd: 'raw_dir_dvd',
|
||||
other: 'raw_dir_other'
|
||||
other: 'raw_dir_other',
|
||||
cd: 'raw_dir_cd'
|
||||
},
|
||||
raw_dir_owner: {
|
||||
bluray: 'raw_dir_bluray_owner',
|
||||
dvd: 'raw_dir_dvd_owner',
|
||||
other: 'raw_dir_other_owner'
|
||||
other: 'raw_dir_other_owner',
|
||||
cd: 'raw_dir_cd_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
|
||||
@@ -63,7 +63,38 @@ function parseHandBrakeProgress(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCdParanoiaProgress(line) {
|
||||
// cdparanoia writes progress to stderr with \r overwrites.
|
||||
// Formats seen in the wild:
|
||||
// "Ripping track 1 of 12 progress: ( 34.21%)"
|
||||
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])"
|
||||
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i);
|
||||
if (progressMatch) {
|
||||
const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i);
|
||||
const currentTrack = trackMatch ? Number(trackMatch[1]) : null;
|
||||
const totalTracks = trackMatch ? Number(trackMatch[2]) : null;
|
||||
return {
|
||||
percent: clampPercent(Number(progressMatch[1])),
|
||||
currentTrack,
|
||||
totalTracks,
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
|
||||
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style – no clear percent here
|
||||
// Fall back to generic percent match
|
||||
const percent = parseGenericPercent(normalized);
|
||||
if (percent !== null) {
|
||||
return { percent, currentTrack: null, totalTracks: null, eta: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMakeMkvProgress,
|
||||
parseHandBrakeProgress
|
||||
parseHandBrakeProgress,
|
||||
parseCdParanoiaProgress
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user