Compare commits
12 Commits
main
...
cd-ripping
| Author | SHA1 | Date | |
|---|---|---|---|
| e0bba8cbfc | |||
| 715dfbbc38 | |||
| 5cf869eaca | |||
| 3dd043689e | |||
| 882dad57e2 | |||
| 15f1a49378 | |||
| 871e39bba2 | |||
| d8a6b4c56d | |||
| 16e76d70c9 | |||
| 45a19c7a12 | |||
| 8af4da5a08 | |||
| c13ce5a50b |
@@ -46,6 +46,65 @@ 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.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) => {
|
||||||
|
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(
|
router.post(
|
||||||
'/select-metadata',
|
'/select-metadata',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
654
backend/src/services/cdRipService.js
Normal file
654
backend/src/services/cdRipService.js
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
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 } = 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 = [];
|
||||||
|
|
||||||
|
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(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
||||||
|
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||||
|
if (sectorValues.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSector = Number(sectorValues[0]);
|
||||||
|
const lengthSector = Number(sectorValues[1]);
|
||||||
|
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration in seconds: sectors / 75
|
||||||
|
const durationSec = Math.round(lengthSector / 75);
|
||||||
|
tracks.push({
|
||||||
|
position,
|
||||||
|
startSector,
|
||||||
|
lengthSector,
|
||||||
|
durationSec,
|
||||||
|
durationMs: durationSec * 1000
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {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
|
||||||
|
} = 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++) {
|
||||||
|
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 ${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) * 50;
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'rip',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
percent: overallPercent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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++) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
const track = tracksToRip[i];
|
||||||
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.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',
|
||||||
|
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++) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
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, 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)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runProcessTracked({
|
||||||
|
cmd: encodeArgs.cmd,
|
||||||
|
args: encodeArgs.args,
|
||||||
|
cwd: rawWavDir,
|
||||||
|
onStdoutLine() {},
|
||||||
|
onStderrLine() {},
|
||||||
|
context,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up WAV after encode
|
||||||
|
try {
|
||||||
|
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 = 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}`,
|
||||||
|
'--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
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ const { execFile } = require('child_process');
|
|||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
const settingsService = require('./settingsService');
|
const settingsService = require('./settingsService');
|
||||||
const logger = require('./logger').child('DISK');
|
const logger = require('./logger').child('DISK');
|
||||||
|
const { parseToc } = require('./cdRipService');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -52,14 +53,17 @@ function normalizeMediaProfile(rawValue) {
|
|||||||
) {
|
) {
|
||||||
return 'dvd';
|
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 'other';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpecificMediaProfile(value) {
|
function isSpecificMediaProfile(value) {
|
||||||
return value === 'bluray' || value === 'dvd';
|
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromTextParts(parts) {
|
function inferMediaProfileFromTextParts(parts) {
|
||||||
@@ -82,6 +86,9 @@ function inferMediaProfileFromTextParts(parts) {
|
|||||||
|
|
||||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||||
const fstype = String(rawFsType || '').trim().toLowerCase();
|
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||||
|
if (fstype === 'audio_cd') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
const model = String(rawModel || '').trim().toLowerCase();
|
const model = String(rawModel || '').trim().toLowerCase();
|
||||||
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
||||||
const hasDvdModelMarker = /dvd/.test(model);
|
const hasDvdModelMarker = /dvd/.test(model);
|
||||||
@@ -142,7 +149,7 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
|||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||||
return 'other';
|
return 'cd';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -493,22 +500,74 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkMediaPresent(devicePath) {
|
async checkMediaPresent(devicePath) {
|
||||||
|
let blkidType = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
const type = String(stdout || '').trim().toLowerCase();
|
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||||
const has = type.length > 0;
|
} catch (_error) {
|
||||||
logger.debug('blkid:result', { devicePath, hasMedia: has, type });
|
// blkid failed – could mean no disc, or an audio CD (no filesystem 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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: cdparanoia can read the TOC of audio CDs directly.
|
||||||
|
// Useful when udev media flags are not propagated (e.g. VM passthrough).
|
||||||
|
// 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 {
|
||||||
|
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||||
|
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
}
|
||||||
|
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
} catch (cdError) {
|
||||||
|
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 });
|
||||||
|
return { hasMedia: false, type: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDiscLabel(devicePath) {
|
async getDiscLabel(devicePath) {
|
||||||
@@ -560,6 +619,11 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async inferMediaProfile(devicePath, hints = {}) {
|
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);
|
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||||
if (isSpecificMediaProfile(explicit)) {
|
if (isSpecificMediaProfile(explicit)) {
|
||||||
return explicit;
|
return explicit;
|
||||||
|
|||||||
169
backend/src/services/musicBrainzService.js
Normal file
169
backend/src/services/musicBrainzService.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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 normalizedTracks = media.flatMap((medium, mediumIdx) => {
|
||||||
|
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
// handles 404s for releases that have no front cover.
|
||||||
|
const coverArtUrl = release.id
|
||||||
|
? `https://coverartarchive.org/release/${release.id}/front-250`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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: normalizedTracks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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+media');
|
||||||
|
|
||||||
|
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+media');
|
||||||
|
|
||||||
|
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 settingsService = require('./settingsService');
|
||||||
const historyService = require('./historyService');
|
const historyService = require('./historyService');
|
||||||
const omdbService = require('./omdbService');
|
const omdbService = require('./omdbService');
|
||||||
|
const musicBrainzService = require('./musicBrainzService');
|
||||||
|
const cdRipService = require('./cdRipService');
|
||||||
const scriptService = require('./scriptService');
|
const scriptService = require('./scriptService');
|
||||||
const scriptChainService = require('./scriptChainService');
|
const scriptChainService = require('./scriptChainService');
|
||||||
const runtimeActivityService = require('./runtimeActivityService');
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
@@ -20,7 +22,7 @@ const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/pl
|
|||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
const userPresetService = require('./userPresetService');
|
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 = [
|
const REVIEW_REFRESH_SETTING_PREFIXES = [
|
||||||
'handbrake_',
|
'handbrake_',
|
||||||
'mediainfo_',
|
'mediainfo_',
|
||||||
@@ -60,6 +62,7 @@ const PRE_ENCODE_PROGRESS_RESERVE = 10;
|
|||||||
const POST_ENCODE_PROGRESS_RESERVE = 10;
|
const POST_ENCODE_PROGRESS_RESERVE = 10;
|
||||||
const POST_ENCODE_FINISH_BUFFER = 1;
|
const POST_ENCODE_FINISH_BUFFER = 1;
|
||||||
const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024;
|
const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024;
|
||||||
|
const MAKEMKV_BACKUP_FAILURE_MSG_CODES = new Set([5069, 5080]);
|
||||||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||||||
const RAW_FOLDER_STATES = Object.freeze({
|
const RAW_FOLDER_STATES = Object.freeze({
|
||||||
@@ -72,6 +75,16 @@ function nowIso() {
|
|||||||
return new Date().toISOString();
|
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) {
|
function normalizeMediaProfile(value) {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -101,14 +114,17 @@ function normalizeMediaProfile(value) {
|
|||||||
) {
|
) {
|
||||||
return 'dvd';
|
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 'other';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpecificMediaProfile(value) {
|
function isSpecificMediaProfile(value) {
|
||||||
return value === 'bluray' || value === 'dvd';
|
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||||
@@ -537,6 +553,37 @@ function composeEncodeScriptStatusText(percent, phase, itemType, index, total, l
|
|||||||
return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`;
|
return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMakeMkvMessageCode(line) {
|
||||||
|
const match = String(line || '').match(/\bMSG:(\d+),/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const code = Number(match[1]);
|
||||||
|
if (!Number.isFinite(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMakeMkvBackupFailureMarker(line) {
|
||||||
|
const text = String(line || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = parseMakeMkvMessageCode(text);
|
||||||
|
if (code !== null && MAKEMKV_BACKUP_FAILURE_MSG_CODES.has(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /backup\s+failed/i.test(text) || /backup\s+fehlgeschlagen/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMakeMkvBackupFailureMarker(lines) {
|
||||||
|
if (!Array.isArray(lines)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lines.find((line) => isMakeMkvBackupFailureMarker(line)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function createEncodeScriptProgressTracker({
|
function createEncodeScriptProgressTracker({
|
||||||
jobId,
|
jobId,
|
||||||
preSteps = 0,
|
preSteps = 0,
|
||||||
@@ -649,7 +696,8 @@ function createEncodeScriptProgressTracker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldKeepHighlight(line) {
|
function shouldKeepHighlight(line) {
|
||||||
return /error|fail|warn|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line);
|
return /error|fail|warn|fehl|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line)
|
||||||
|
|| isMakeMkvBackupFailureMarker(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeNonNegativeInteger(rawValue) {
|
function normalizeNonNegativeInteger(rawValue) {
|
||||||
@@ -5114,6 +5162,11 @@ class PipelineService extends EventEmitter {
|
|||||||
mediaProfile
|
mediaProfile
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Route audio CDs to the dedicated CD pipeline
|
||||||
|
if (mediaProfile === 'cd') {
|
||||||
|
return this.analyzeCd(deviceWithProfile);
|
||||||
|
}
|
||||||
|
|
||||||
const job = await historyService.createJob({
|
const job = await historyService.createJob({
|
||||||
discDevice: device.path,
|
discDevice: device.path,
|
||||||
status: 'METADATA_SELECTION',
|
status: 'METADATA_SELECTION',
|
||||||
@@ -8606,13 +8659,14 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for MakeMKV backup failure even when exit code is 0.
|
// Check for MakeMKV backup failure even when exit code is 0.
|
||||||
// MakeMKV can exit 0 but still output "Backup failed" in stdout.
|
// MakeMKV can emit localized failure text while still exiting with 0.
|
||||||
const backupFailed = Array.isArray(makemkvInfo?.highlights) &&
|
const backupFailureLine = ripMode === 'backup'
|
||||||
makemkvInfo.highlights.some(line => /backup failed/i.test(line));
|
? findMakeMkvBackupFailureMarker(makemkvInfo?.highlights)
|
||||||
if (backupFailed) {
|
: null;
|
||||||
const failMsg = makemkvInfo.highlights.find(line => /backup failed/i.test(line)) || 'Backup failed';
|
if (backupFailureLine) {
|
||||||
|
const msgCode = parseMakeMkvMessageCode(backupFailureLine);
|
||||||
throw Object.assign(
|
throw Object.assign(
|
||||||
new Error(`MakeMKV Backup fehlgeschlagen (Exit Code 0): ${failMsg}`),
|
new Error(`MakeMKV Backup fehlgeschlagen${msgCode !== null ? ` (MSG:${msgCode})` : ''}: ${backupFailureLine}`),
|
||||||
{ runInfo: makemkvInfo }
|
{ runInfo: makemkvInfo }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9752,6 +9806,556 @@ 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 });
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
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.`
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
eta: null,
|
||||||
|
statusText: 'CD-Metadaten auswählen',
|
||||||
|
context: {
|
||||||
|
jobId: job.id,
|
||||||
|
device,
|
||||||
|
mediaProfile: 'cd',
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
cdparanoiaCommandPreview,
|
||||||
|
detectedTitle,
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { jobId: job.id, detectedTitle, tracks };
|
||||||
|
} 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 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,
|
||||||
|
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;
|
||||||
|
const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`;
|
||||||
|
const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
title: resolvedTitle,
|
||||||
|
artist: resolvedArtist,
|
||||||
|
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)) {
|
||||||
|
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,
|
||||||
|
eta: null,
|
||||||
|
statusText: 'CD bereit zum Rippen',
|
||||||
|
context: {
|
||||||
|
...(this.snapshot.context || {}),
|
||||||
|
jobId,
|
||||||
|
mediaProfile: 'cd',
|
||||||
|
tracks: mergedTracks,
|
||||||
|
selectedMetadata: { title, artist, year, mbId, coverUrl },
|
||||||
|
devicePath: resolvedDevicePath,
|
||||||
|
cdparanoiaCmd: resolvedCdparanoiaCmd,
|
||||||
|
cdparanoiaCommandPreview
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(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 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 cdOutputOwner = String(settings.raw_dir_owner || '').trim();
|
||||||
|
const jobDir = `CD_Job${jobId}_${Date.now()}`;
|
||||||
|
const rawWavDir = path.join(cdBaseDir, '.tmp', jobDir, 'wav');
|
||||||
|
const cdTempJobDir = path.dirname(rawWavDir);
|
||||||
|
const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate);
|
||||||
|
ensureDir(cdBaseDir);
|
||||||
|
ensureDir(cdTempJobDir);
|
||||||
|
ensureDir(outputDir);
|
||||||
|
chownRecursive(cdTempJobDir, cdOutputOwner);
|
||||||
|
chownRecursive(outputDir, cdOutputOwner);
|
||||||
|
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: null,
|
||||||
|
output_path: outputDir,
|
||||||
|
makemkv_info_json: JSON.stringify(updatedCdInfo),
|
||||||
|
encode_plan_json: JSON.stringify({
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
selectedTracks: effectiveSelectedTrackPositions,
|
||||||
|
tracks: mergedTracks,
|
||||||
|
outputTemplate: cdOutputTemplate
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.setState('CD_RIPPING', {
|
||||||
|
activeJobId: jobId,
|
||||||
|
progress: 0,
|
||||||
|
eta: null,
|
||||||
|
statusText: 'CD wird gerippt …',
|
||||||
|
context: {
|
||||||
|
...(this.snapshot.context || {}),
|
||||||
|
jobId,
|
||||||
|
mediaProfile: 'cd',
|
||||||
|
tracks: mergedTracks,
|
||||||
|
selectedMetadata: effectiveSelectedMeta,
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
rawWavDir,
|
||||||
|
outputTemplate: cdOutputTemplate,
|
||||||
|
cdparanoiaCommandPreview
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
jobId,
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
outputTemplate: cdOutputTemplate,
|
||||||
|
outputOwner: cdOutputOwner,
|
||||||
|
selectedTrackPositions: effectiveSelectedTrackPositions,
|
||||||
|
tocTracks: mergedTracks,
|
||||||
|
selectedMeta: effectiveSelectedMeta
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error('cd:rip:unhandled', { jobId, error: errorToMeta(error) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId, started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _runCdRip({
|
||||||
|
jobId,
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
outputTemplate,
|
||||||
|
outputOwner,
|
||||||
|
selectedTrackPositions,
|
||||||
|
tocTracks,
|
||||||
|
selectedMeta
|
||||||
|
}) {
|
||||||
|
const processKey = Number(jobId);
|
||||||
|
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,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
outputTemplate,
|
||||||
|
selectedTracks: selectedTrackPositions,
|
||||||
|
tracks: tocTracks,
|
||||||
|
meta: selectedMeta,
|
||||||
|
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 (normalizedPhase === 'encode' && !encodeStateApplied) {
|
||||||
|
encodeStateApplied = true;
|
||||||
|
await historyService.updateJob(jobId, {
|
||||||
|
status: 'CD_ENCODING',
|
||||||
|
last_state: 'CD_ENCODING'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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, {
|
||||||
|
status: 'FINISHED',
|
||||||
|
last_state: 'FINISHED',
|
||||||
|
end_time: nowIso(),
|
||||||
|
rip_successful: 1,
|
||||||
|
output_path: outputDir
|
||||||
|
});
|
||||||
|
chownRecursive(outputDir, outputOwner);
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new PipelineService();
|
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 SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'other', 'cd'];
|
||||||
const PROFILED_SETTINGS = {
|
const PROFILED_SETTINGS = {
|
||||||
raw_dir: {
|
raw_dir: {
|
||||||
bluray: 'raw_dir_bluray',
|
bluray: 'raw_dir_bluray',
|
||||||
dvd: 'raw_dir_dvd',
|
dvd: 'raw_dir_dvd',
|
||||||
other: 'raw_dir_other'
|
other: 'raw_dir_other',
|
||||||
|
cd: 'raw_dir_cd'
|
||||||
},
|
},
|
||||||
raw_dir_owner: {
|
raw_dir_owner: {
|
||||||
bluray: 'raw_dir_bluray_owner',
|
bluray: 'raw_dir_bluray_owner',
|
||||||
dvd: 'raw_dir_dvd_owner',
|
dvd: 'raw_dir_dvd_owner',
|
||||||
other: 'raw_dir_other_owner'
|
other: 'raw_dir_other_owner',
|
||||||
|
cd: 'raw_dir_cd_owner'
|
||||||
},
|
},
|
||||||
movie_dir: {
|
movie_dir: {
|
||||||
bluray: 'movie_dir_bluray',
|
bluray: 'movie_dir_bluray',
|
||||||
@@ -1315,6 +1317,13 @@ class SettingsService {
|
|||||||
return `dev:${device}`;
|
return `dev:${device}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const devicePath = String(deviceInfo?.path || '').trim();
|
||||||
|
if (devicePath) {
|
||||||
|
// Prefer stable Linux device path over MakeMKV disc index mapping.
|
||||||
|
// MakeMKV drive indices (disc:N) do not reliably match /dev/srN numbering.
|
||||||
|
return `dev:${devicePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
|
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
|
||||||
return `disc:${deviceInfo.index}`;
|
return `disc:${deviceInfo.index}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,38 @@ function parseHandBrakeProgress(line) {
|
|||||||
return null;
|
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 = {
|
module.exports = {
|
||||||
parseMakeMkvProgress,
|
parseMakeMkvProgress,
|
||||||
parseHandBrakeProgress
|
parseHandBrakeProgress,
|
||||||
|
parseCdParanoiaProgress
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -340,6 +340,36 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540);
|
VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL);
|
||||||
|
|
||||||
|
-- Tools – CD
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', '/opt/ripster/backend/data/output/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_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
|
||||||
|
|
||||||
-- Metadaten
|
-- Metadaten
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||||
@@ -349,6 +379,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
||||||
|
|
||||||
-- Benachrichtigungen
|
-- Benachrichtigungen
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
||||||
|
|||||||
@@ -273,6 +273,28 @@ export const api = {
|
|||||||
searchOmdb(q) {
|
searchOmdb(q) {
|
||||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||||
},
|
},
|
||||||
|
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',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async startCdRip(jobId, ripConfig) {
|
||||||
|
const result = await request(`/pipeline/cd/start/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ripConfig || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async selectMetadata(payload) {
|
async selectMetadata(payload) {
|
||||||
const result = await request('/pipeline/select-metadata', {
|
const result = await request('/pipeline/select-metadata', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
313
frontend/src/components/CdMetadataDialog.jsx
Normal file
313
frontend/src/components/CdMetadataDialog.jsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className="poster-thumb-lg"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
visible,
|
||||||
|
context,
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Track titles are pre-filled from MusicBrainz and edited in the next step.
|
||||||
|
const [trackTitles, setTrackTitles] = useState({});
|
||||||
|
|
||||||
|
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected(null);
|
||||||
|
setQuery('');
|
||||||
|
setManualTitle(context?.detectedTitle || '');
|
||||||
|
setManualArtist('');
|
||||||
|
setManualYear(null);
|
||||||
|
setResults([]);
|
||||||
|
setSearchBusy(false);
|
||||||
|
|
||||||
|
const titles = {};
|
||||||
|
for (const t of tocTracks) {
|
||||||
|
titles[t.position] = t.title || `Track ${t.position}`;
|
||||||
|
}
|
||||||
|
setTrackTitles(titles);
|
||||||
|
}, [visible, context]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setManualTitle(selected.title || '');
|
||||||
|
setManualArtist(selected.artist || '');
|
||||||
|
setManualYear(selected.year || null);
|
||||||
|
|
||||||
|
// Pre-fill track titles from the MusicBrainz result
|
||||||
|
if (Array.isArray(selected.tracks) && selected.tracks.length > 0) {
|
||||||
|
const titles = {};
|
||||||
|
for (const t of selected.tracks) {
|
||||||
|
if (t.position <= tocTracks.length) {
|
||||||
|
titles[t.position] = t.title || `Track ${t.position}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fill any remaining tracks not in MB result
|
||||||
|
for (const t of tocTracks) {
|
||||||
|
if (!titles[t.position]) {
|
||||||
|
titles[t.position] = t.title || `Track ${t.position}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTrackTitles(titles);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
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: releaseDetails?.mbId || selected?.mbId || null,
|
||||||
|
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
|
||||||
|
tracks
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mbTitleBody = (row) => (
|
||||||
|
<div className="mb-result-row">
|
||||||
|
<CoverThumb url={row.coverArtUrl} alt={row.title} />
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.title}</strong></div>
|
||||||
|
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
|
||||||
|
{row.label ? <small> | {row.label}</small> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
header="CD-Metadaten auswählen"
|
||||||
|
visible={visible}
|
||||||
|
onHide={onHide}
|
||||||
|
style={{ width: '58rem', maxWidth: '97vw' }}
|
||||||
|
className="cd-metadata-dialog"
|
||||||
|
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
{/* MusicBrainz search */}
|
||||||
|
<div className="search-row">
|
||||||
|
<InputText
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="Album / Interpret suchen"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="MusicBrainz Suche"
|
||||||
|
icon="pi pi-search"
|
||||||
|
onClick={handleSearch}
|
||||||
|
loading={busy || searchBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="table-scroll-wrap table-scroll-medium">
|
||||||
|
<DataTable
|
||||||
|
value={results}
|
||||||
|
selectionMode="single"
|
||||||
|
selection={selected}
|
||||||
|
onSelectionChange={(e) => setSelected(e.value)}
|
||||||
|
dataKey="mbId"
|
||||||
|
size="small"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="16rem"
|
||||||
|
emptyMessage="Keine Treffer"
|
||||||
|
>
|
||||||
|
<Column header="Album" body={mbTitleBody} />
|
||||||
|
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||||
|
<Column field="country" header="Land" style={{ width: '6rem' }} />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Manual metadata */}
|
||||||
|
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
|
||||||
|
<div className="metadata-grid">
|
||||||
|
<InputText
|
||||||
|
value={manualTitle}
|
||||||
|
onChange={(e) => setManualTitle(e.target.value)}
|
||||||
|
placeholder="Album-Titel"
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
value={manualArtist}
|
||||||
|
onChange={(e) => setManualArtist(e.target.value)}
|
||||||
|
placeholder="Interpret / Band"
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
value={manualYear}
|
||||||
|
onValueChange={(e) => setManualYear(e.value)}
|
||||||
|
placeholder="Jahr"
|
||||||
|
useGrouping={false}
|
||||||
|
min={1900}
|
||||||
|
max={2100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track selection/editing moved to CD-Rip configuration panel */}
|
||||||
|
{tocTracks.length > 0 ? (
|
||||||
|
<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' }}>
|
||||||
|
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
|
||||||
|
<Button
|
||||||
|
label="Weiter"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!manualTitle.trim() && !context?.detectedTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
672
frontend/src/components/CdRipConfigPanel.jsx
Normal file
672
frontend/src/components/CdRipConfigPanel.jsx
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
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) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return values[field.showWhen.field] === field.showWhen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatField({ field, value, onChange }) {
|
||||||
|
if (field.type === 'slider') {
|
||||||
|
return (
|
||||||
|
<div className="cd-format-field">
|
||||||
|
<label>
|
||||||
|
{field.label}: <strong>{value}</strong>
|
||||||
|
</label>
|
||||||
|
{field.description ? <small>{field.description}</small> : null}
|
||||||
|
<Slider
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.key, e.value)}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step || 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'select') {
|
||||||
|
return (
|
||||||
|
<div className="cd-format-field">
|
||||||
|
<label>{field.label}</label>
|
||||||
|
{field.description ? <small>{field.description}</small> : null}
|
||||||
|
<Dropdown
|
||||||
|
value={value}
|
||||||
|
options={field.options}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(e) => onChange(field.key, e.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
onCancel,
|
||||||
|
busy
|
||||||
|
}) {
|
||||||
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
|
const tracks = Array.isArray(context.tracks) ? context.tracks : [];
|
||||||
|
const selectedMeta = context.selectedMetadata || {};
|
||||||
|
const state = String(pipeline?.state || '').trim().toUpperCase();
|
||||||
|
|
||||||
|
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
|
||||||
|
const isFinished = state === 'FINISHED';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleTrack = (position) => {
|
||||||
|
setSelectedTracks((prev) => ({ ...prev, [position]: !prev[position] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAll = () => {
|
||||||
|
const allSelected = tracks.every((t) => {
|
||||||
|
const position = normalizePosition(t?.position);
|
||||||
|
return position ? selectedTracks[position] !== false : false;
|
||||||
|
});
|
||||||
|
const map = {};
|
||||||
|
for (const t of tracks) {
|
||||||
|
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 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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStart && onStart({
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
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) => {
|
||||||
|
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();
|
||||||
|
const statusText = String(pipeline?.statusText || '').trim();
|
||||||
|
|
||||||
|
if (isRipping || isFinished) {
|
||||||
|
return (
|
||||||
|
<div className="cd-rip-config-panel">
|
||||||
|
<div className="cd-rip-status">
|
||||||
|
<Tag
|
||||||
|
value={state === 'CD_RIPPING' ? 'Ripping' : state === 'CD_ENCODING' ? 'Encodierung' : 'Fertig'}
|
||||||
|
severity={isFinished ? 'success' : 'info'}
|
||||||
|
/>
|
||||||
|
{statusText ? <small>{statusText}</small> : null}
|
||||||
|
<small>1) {cdparanoiaCommandPreview}</small>
|
||||||
|
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
|
||||||
|
{!isFinished ? (
|
||||||
|
<>
|
||||||
|
<ProgressBar value={clampedProgress} showValue={false} />
|
||||||
|
<small>{Math.round(clampedProgress)}%{eta ? ` | ETA ${eta}` : ''}</small>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!isFinished ? (
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onCancel && onCancel()}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cd-rip-config-panel">
|
||||||
|
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
|
||||||
|
|
||||||
|
<div className="cd-meta-summary">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Format selection */}
|
||||||
|
<div className="cd-format-field">
|
||||||
|
<label>Ausgabeformat</label>
|
||||||
|
<Dropdown
|
||||||
|
value={format}
|
||||||
|
options={CD_FORMATS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(e) => setFormat(e.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format-specific options */}
|
||||||
|
{visibleFields.map((field) => (
|
||||||
|
<FormatField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={formatOptions[field.key] ?? field.default}
|
||||||
|
onChange={handleFormatOptionChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Track selection */}
|
||||||
|
{tracks.length > 0 ? (
|
||||||
|
<div className="cd-track-selection">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||||
|
<strong>Tracks ({selectedCount} / {tracks.length} ausgewählt)</strong>
|
||||||
|
<Button
|
||||||
|
label={selectedCount === tracks.length ? 'Alle abwählen' : 'Alle auswählen'}
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={handleToggleAll}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</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 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 (
|
||||||
|
<tr key={position} className={isSelected ? 'selected' : ''}>
|
||||||
|
<td className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleToggleTrack(position)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
label="Abbrechen"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onCancel && onCancel()}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Rip starten"
|
||||||
|
icon="pi pi-play"
|
||||||
|
onClick={handleStart}
|
||||||
|
loading={busy}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/config/cdFormatSchemas.js
Normal file
126
frontend/src/config/cdFormatSchemas.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* CD output format schemas.
|
||||||
|
* Each format defines the fields shown in CdRipConfigPanel.
|
||||||
|
*/
|
||||||
|
export const CD_FORMATS = [
|
||||||
|
{ label: 'FLAC (verlustlos)', value: 'flac' },
|
||||||
|
{ label: 'MP3', value: 'mp3' },
|
||||||
|
{ label: 'Opus', value: 'opus' },
|
||||||
|
{ label: 'OGG Vorbis', value: 'ogg' },
|
||||||
|
{ label: 'WAV (unkomprimiert)', value: 'wav' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CD_FORMAT_SCHEMAS = {
|
||||||
|
wav: {
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
|
||||||
|
flac: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'flacCompression',
|
||||||
|
label: 'Kompressionsstufe',
|
||||||
|
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
|
||||||
|
type: 'slider',
|
||||||
|
min: 0,
|
||||||
|
max: 8,
|
||||||
|
step: 1,
|
||||||
|
default: 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
mp3: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'mp3Mode',
|
||||||
|
label: 'Modus',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
|
||||||
|
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
|
||||||
|
],
|
||||||
|
default: 'cbr'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mp3Bitrate',
|
||||||
|
label: 'Bitrate (kbps)',
|
||||||
|
type: 'select',
|
||||||
|
showWhen: { field: 'mp3Mode', value: 'cbr' },
|
||||||
|
options: [
|
||||||
|
{ label: '128 kbps', value: 128 },
|
||||||
|
{ label: '160 kbps', value: 160 },
|
||||||
|
{ label: '192 kbps', value: 192 },
|
||||||
|
{ label: '256 kbps', value: 256 },
|
||||||
|
{ label: '320 kbps', value: 320 }
|
||||||
|
],
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mp3Quality',
|
||||||
|
label: 'VBR Qualität (V0–V9)',
|
||||||
|
description: '0 = beste Qualität, 9 = kleinste Datei',
|
||||||
|
type: 'slider',
|
||||||
|
min: 0,
|
||||||
|
max: 9,
|
||||||
|
step: 1,
|
||||||
|
showWhen: { field: 'mp3Mode', value: 'vbr' },
|
||||||
|
default: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
opus: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'opusBitrate',
|
||||||
|
label: 'Bitrate (kbps)',
|
||||||
|
description: 'Empfohlen: 96–192 kbps für Musik',
|
||||||
|
type: 'slider',
|
||||||
|
min: 32,
|
||||||
|
max: 512,
|
||||||
|
step: 8,
|
||||||
|
default: 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'opusComplexity',
|
||||||
|
label: 'Encoder-Komplexität',
|
||||||
|
description: '0 = schnell, 10 = beste Qualität',
|
||||||
|
type: 'slider',
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
default: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
ogg: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'oggQuality',
|
||||||
|
label: 'Qualität',
|
||||||
|
description: '-1 = kleinste Datei, 10 = beste Qualität. Empfohlen: 5–7.',
|
||||||
|
type: 'slider',
|
||||||
|
min: -1,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
default: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultFormatOptions(format) {
|
||||||
|
const schema = CD_FORMAT_SCHEMAS[format];
|
||||||
|
if (!schema) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const defaults = {};
|
||||||
|
for (const field of schema.fields) {
|
||||||
|
if (field.default !== undefined) {
|
||||||
|
defaults[field.key] = field.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ import { InputNumber } from 'primereact/inputnumber';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import PipelineStatusCard from '../components/PipelineStatusCard';
|
import PipelineStatusCard from '../components/PipelineStatusCard';
|
||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||||
|
import CdRipConfigPanel from '../components/CdRipConfigPanel';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const dashboardStatuses = new Set([
|
const dashboardStatuses = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
@@ -25,7 +27,12 @@ const dashboardStatuses = new Set([
|
|||||||
'RIPPING',
|
'RIPPING',
|
||||||
'ENCODING',
|
'ENCODING',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'ERROR'
|
'ERROR',
|
||||||
|
'CD_METADATA_SELECTION',
|
||||||
|
'CD_READY_TO_RIP',
|
||||||
|
'CD_ANALYZING',
|
||||||
|
'CD_RIPPING',
|
||||||
|
'CD_ENCODING'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
@@ -362,32 +369,25 @@ function resolveMediaType(job) {
|
|||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaIndicatorMeta(job) {
|
function mediaIndicatorMeta(job) {
|
||||||
const mediaType = resolveMediaType(job);
|
const mediaType = resolveMediaType(job);
|
||||||
return mediaType === 'bluray'
|
if (mediaType === 'bluray') {
|
||||||
? {
|
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
|
||||||
mediaType,
|
|
||||||
src: blurayIndicatorIcon,
|
|
||||||
alt: 'Blu-ray',
|
|
||||||
title: 'Blu-ray'
|
|
||||||
}
|
}
|
||||||
: mediaType === 'dvd'
|
if (mediaType === 'dvd') {
|
||||||
? {
|
return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' };
|
||||||
mediaType,
|
|
||||||
src: discIndicatorIcon,
|
|
||||||
alt: 'DVD',
|
|
||||||
title: 'DVD'
|
|
||||||
}
|
}
|
||||||
: {
|
if (mediaType === 'cd') {
|
||||||
mediaType,
|
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
||||||
src: otherIndicatorIcon,
|
}
|
||||||
alt: 'Sonstiges Medium',
|
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
||||||
title: 'Sonstiges Medium'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
||||||
@@ -423,7 +423,48 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
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 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 mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
const inputPath = isPreRip
|
const inputPath = isPreRip
|
||||||
@@ -468,17 +509,17 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
jobId,
|
jobId,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
detectedTitle: job?.detected_title || null,
|
detectedTitle: job?.detected_title || null,
|
||||||
|
mediaProfile: resolveMediaType(job),
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
cdparanoiaCommandPreview,
|
||||||
|
tracks: cdTracks,
|
||||||
inputPath,
|
inputPath,
|
||||||
hasEncodableTitle,
|
hasEncodableTitle,
|
||||||
reviewConfirmed,
|
reviewConfirmed,
|
||||||
mode,
|
mode,
|
||||||
sourceJobId: encodePlan?.sourceJobId || null,
|
sourceJobId: encodePlan?.sourceJobId || null,
|
||||||
selectedMetadata: {
|
selectedMetadata,
|
||||||
title: job?.title || job?.detected_title || null,
|
|
||||||
year: job?.year || null,
|
|
||||||
imdbId: job?.imdb_id || null,
|
|
||||||
poster: job?.poster_url || null
|
|
||||||
},
|
|
||||||
mediaInfoReview: encodePlan,
|
mediaInfoReview: encodePlan,
|
||||||
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
||||||
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
||||||
@@ -502,6 +543,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
...computedContext,
|
...computedContext,
|
||||||
...existingContext,
|
...existingContext,
|
||||||
rawPath: existingContext.rawPath || computedContext.rawPath,
|
rawPath: existingContext.rawPath || computedContext.rawPath,
|
||||||
|
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||||
|
? existingContext.tracks
|
||||||
|
: computedContext.tracks,
|
||||||
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
||||||
canRestartEncodeFromLastSettings:
|
canRestartEncodeFromLastSettings:
|
||||||
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
||||||
@@ -547,6 +591,9 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
|
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
||||||
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
|
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
|
||||||
visible: false,
|
visible: false,
|
||||||
jobId: null,
|
jobId: null,
|
||||||
@@ -664,6 +711,24 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);
|
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);
|
||||||
|
|
||||||
|
// Auto-open CD metadata dialog when pipeline enters CD_METADATA_SELECTION
|
||||||
|
useEffect(() => {
|
||||||
|
const currentState = String(pipeline?.state || '').trim().toUpperCase();
|
||||||
|
if (currentState === 'CD_METADATA_SELECTION') {
|
||||||
|
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
||||||
|
if (ctx?.jobId && !cdMetadataDialogVisible) {
|
||||||
|
setCdMetadataDialogContext(ctx);
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentState === 'CD_READY_TO_RIP') {
|
||||||
|
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
||||||
|
if (ctx?.jobId) {
|
||||||
|
setCdRipPanelJobId(ctx.jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pipeline?.state, pipeline?.context?.jobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueueState(normalizeQueue(pipeline?.queue));
|
setQueueState(normalizeQueue(pipeline?.queue));
|
||||||
}, [pipeline?.queue]);
|
}, [pipeline?.queue]);
|
||||||
@@ -1322,6 +1387,57 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMusicBrainzSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchMusicBrainz(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await api.selectCdMetadata(payload);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCdRipStart = async (jobId, ripConfig) => {
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobBusy(jobId, true);
|
||||||
|
try {
|
||||||
|
await api.startCdRip(jobId, ripConfig);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setJobBusy(jobId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const device = lastDiscEvent || pipeline?.context?.device;
|
const device = lastDiscEvent || pipeline?.context?.device;
|
||||||
const canReanalyze = state === 'ENCODING'
|
const canReanalyze = state === 'ENCODING'
|
||||||
? Boolean(device)
|
? Boolean(device)
|
||||||
@@ -2034,6 +2150,40 @@ export default function DashboardPage({
|
|||||||
disabled={busyJobIds.has(jobId)}
|
disabled={busyJobIds.has(jobId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||||
|
const isCdJob = jobState.startsWith('CD_');
|
||||||
|
if (isCdJob) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jobState === 'CD_METADATA_SELECTION' ? (
|
||||||
|
<Button
|
||||||
|
label="CD-Metadaten auswählen"
|
||||||
|
icon="pi pi-list"
|
||||||
|
onClick={() => {
|
||||||
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||||
|
? pipelineForJob.context
|
||||||
|
: pipeline?.context || {};
|
||||||
|
setCdMetadataDialogContext({ ...ctx, jobId });
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
}}
|
||||||
|
disabled={busyJobIds.has(jobId)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
|
||||||
|
<CdRipConfigPanel
|
||||||
|
pipeline={pipelineForJob}
|
||||||
|
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||||
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
|
busy={busyJobIds.has(jobId)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
|
||||||
<PipelineStatusCard
|
<PipelineStatusCard
|
||||||
pipeline={pipelineForJob}
|
pipeline={pipelineForJob}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
@@ -2051,6 +2201,7 @@ export default function DashboardPage({
|
|||||||
busy={busyJobIds.has(jobId)}
|
busy={busyJobIds.has(jobId)}
|
||||||
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2165,6 +2316,19 @@ export default function DashboardPage({
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CdMetadataDialog
|
||||||
|
visible={cdMetadataDialogVisible}
|
||||||
|
context={cdMetadataDialogContext || pipeline?.context || {}}
|
||||||
|
onHide={() => {
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCdMetadataSubmit}
|
||||||
|
onSearch={handleMusicBrainzSearch}
|
||||||
|
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
|
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
|
||||||
visible={Boolean(cancelCleanupDialog.visible)}
|
visible={Boolean(cancelCleanupDialog.visible)}
|
||||||
|
|||||||
@@ -1095,6 +1095,101 @@ body {
|
|||||||
gap: 0.5rem;
|
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 {
|
.device-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2220,6 +2315,10 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cd-track-table {
|
||||||
|
min-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
.script-list {
|
.script-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ const STATUS_LABELS = {
|
|||||||
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
|
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
|
||||||
FINISHED: 'Fertig',
|
FINISHED: 'Fertig',
|
||||||
CANCELLED: 'Abgebrochen',
|
CANCELLED: 'Abgebrochen',
|
||||||
ERROR: 'Fehler'
|
ERROR: 'Fehler',
|
||||||
|
CD_ANALYZING: 'CD-Analyse',
|
||||||
|
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
||||||
|
CD_READY_TO_RIP: 'CD bereit zum Rippen',
|
||||||
|
CD_RIPPING: 'CD rippen',
|
||||||
|
CD_ENCODING: 'CD encodieren'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROCESS_STATUS_LABELS = {
|
const PROCESS_STATUS_LABELS = {
|
||||||
@@ -46,6 +51,8 @@ export function getStatusSeverity(status, options = {}) {
|
|||||||
if (normalized === 'ERROR') return 'danger';
|
if (normalized === 'ERROR') return 'danger';
|
||||||
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
|
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
|
||||||
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
|
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
|
||||||
|
if (normalized === 'CD_READY_TO_RIP') return 'info';
|
||||||
|
if (normalized === 'CD_METADATA_SELECTION') return 'warning';
|
||||||
if (
|
if (
|
||||||
normalized === 'RIPPING'
|
normalized === 'RIPPING'
|
||||||
|| normalized === 'ENCODING'
|
|| normalized === 'ENCODING'
|
||||||
@@ -53,6 +60,9 @@ export function getStatusSeverity(status, options = {}) {
|
|||||||
|| normalized === 'MEDIAINFO_CHECK'
|
|| normalized === 'MEDIAINFO_CHECK'
|
||||||
|| normalized === 'METADATA_SELECTION'
|
|| normalized === 'METADATA_SELECTION'
|
||||||
|| normalized === 'POST_ENCODE_SCRIPTS'
|
|| normalized === 'POST_ENCODE_SCRIPTS'
|
||||||
|
|| normalized === 'CD_ANALYZING'
|
||||||
|
|| normalized === 'CD_RIPPING'
|
||||||
|
|| normalized === 'CD_ENCODING'
|
||||||
) {
|
) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,6 +658,15 @@ else
|
|||||||
ok "Benutzer '$SERVICE_USER' angelegt"
|
ok "Benutzer '$SERVICE_USER' angelegt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||||
|
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
||||||
|
SERVICE_HOME="/home/$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$SERVICE_HOME"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
||||||
|
|
||||||
# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
|
# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
|
||||||
for grp in cdrom optical disk; do
|
for grp in cdrom optical disk; do
|
||||||
if getent group "$grp" &>/dev/null; then
|
if getent group "$grp" &>/dev/null; then
|
||||||
@@ -767,22 +776,15 @@ chmod -R 755 "$INSTALL_DIR"
|
|||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
||||||
ACTUAL_USER="${SUDO_USER:-}"
|
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
||||||
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
||||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" | cut -d: -f6)"
|
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
||||||
if [[ -z "$ACTUAL_HOME" ]]; then
|
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
||||||
ACTUAL_HOME="/home/$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
MAKEMKV_USER_DIR="${ACTUAL_HOME}/.MakeMKV"
|
|
||||||
if [[ ! -d "$MAKEMKV_USER_DIR" ]]; then
|
|
||||||
mkdir -p "$MAKEMKV_USER_DIR"
|
|
||||||
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_USER_DIR"
|
|
||||||
else
|
else
|
||||||
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_USER_DIR"
|
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
||||||
fi
|
|
||||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$MAKEMKV_USER_DIR" 2>/dev/null || true
|
|
||||||
chmod 700 "$MAKEMKV_USER_DIR" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
# --- Systemd-Dienst: Backend -------------------------------------------------
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
header "Systemd-Dienst (Backend) erstellen"
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
@@ -807,6 +809,10 @@ StartLimitBurst=3
|
|||||||
|
|
||||||
# Umgebung
|
# Umgebung
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
|
Environment=HOME=${SERVICE_HOME}
|
||||||
|
Environment=LANG=C.UTF-8
|
||||||
|
Environment=LC_ALL=C.UTF-8
|
||||||
|
Environment=LANGUAGE=C.UTF-8
|
||||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
@@ -820,7 +826,7 @@ SyslogIdentifier=ripster-backend
|
|||||||
NoNewPrivileges=false
|
NoNewPrivileges=false
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
71
install.sh
71
install.sh
@@ -44,7 +44,7 @@ header() { echo -e "\n${BOLD}${BLUE}══════════════
|
|||||||
fatal() { error "$*"; exit 1; }
|
fatal() { error "$*"; exit 1; }
|
||||||
|
|
||||||
# --- Standard-Optionen --------------------------------------------------------
|
# --- Standard-Optionen --------------------------------------------------------
|
||||||
GIT_BRANCH="dev"
|
GIT_BRANCH="cd-ripping"
|
||||||
INSTALL_DIR="/opt/ripster"
|
INSTALL_DIR="/opt/ripster"
|
||||||
SERVICE_USER="ripster"
|
SERVICE_USER="ripster"
|
||||||
BACKEND_PORT="3001"
|
BACKEND_PORT="3001"
|
||||||
@@ -359,6 +359,16 @@ apt-get install -y \
|
|||||||
|
|
||||||
ok "Basispakete installiert"
|
ok "Basispakete installiert"
|
||||||
|
|
||||||
|
info "Installiere CD-Ripping-Tools..."
|
||||||
|
apt-get install -y \
|
||||||
|
cdparanoia \
|
||||||
|
flac \
|
||||||
|
lame \
|
||||||
|
opus-tools \
|
||||||
|
vorbis-tools
|
||||||
|
|
||||||
|
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
|
||||||
|
|
||||||
install_node
|
install_node
|
||||||
|
|
||||||
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
||||||
@@ -392,6 +402,15 @@ else
|
|||||||
ok "Benutzer '$SERVICE_USER' angelegt"
|
ok "Benutzer '$SERVICE_USER' angelegt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||||
|
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
||||||
|
SERVICE_HOME="/home/$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$SERVICE_HOME"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
||||||
|
|
||||||
for grp in cdrom optical disk video render; do
|
for grp in cdrom optical disk video render; do
|
||||||
if getent group "$grp" &>/dev/null; then
|
if getent group "$grp" &>/dev/null; then
|
||||||
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
||||||
@@ -402,6 +421,13 @@ done
|
|||||||
# --- Repository klonen / aktualisieren ----------------------------------------
|
# --- Repository klonen / aktualisieren ----------------------------------------
|
||||||
header "Repository holen (Git)"
|
header "Repository holen (Git)"
|
||||||
|
|
||||||
|
# Prüfen ob der gewünschte Branch auf dem Remote existiert
|
||||||
|
info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
|
||||||
|
if ! git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" &>/dev/null; then
|
||||||
|
fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: $(git ls-remote --heads "$REPO_URL" | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
ok "Branch '$GIT_BRANCH' gefunden"
|
||||||
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
if [[ "$REINSTALL" == true ]]; then
|
if [[ "$REINSTALL" == true ]]; then
|
||||||
info "Aktualisiere bestehendes Repository..."
|
info "Aktualisiere bestehendes Repository..."
|
||||||
@@ -414,8 +440,10 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|||||||
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
|
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
|
||||||
# (z.B. ripster-Serviceuser nach erstem Install)
|
# (z.B. ripster-Serviceuser nach erstem Install)
|
||||||
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
|
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
|
||||||
|
git -C "$INSTALL_DIR" remote set-branches origin '*'
|
||||||
git -C "$INSTALL_DIR" fetch --quiet origin
|
git -C "$INSTALL_DIR" fetch --quiet origin
|
||||||
git -C "$INSTALL_DIR" checkout --quiet "$GIT_BRANCH"
|
git -C "$INSTALL_DIR" reset --hard HEAD
|
||||||
|
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
|
||||||
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
|
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
|
||||||
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
|
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
|
||||||
else
|
else
|
||||||
@@ -434,6 +462,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
|
|||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||||
|
|
||||||
# Gesicherte Daten zurückspielen
|
# Gesicherte Daten zurückspielen
|
||||||
@@ -510,25 +539,22 @@ if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
|||||||
"$INSTALL_DIR/backend/data/output" \
|
"$INSTALL_DIR/backend/data/output" \
|
||||||
"$INSTALL_DIR/backend/data/logs"
|
"$INSTALL_DIR/backend/data/logs"
|
||||||
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
||||||
|
|
||||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
|
||||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" | cut -d: -f6)"
|
|
||||||
if [[ -z "$ACTUAL_HOME" ]]; then
|
|
||||||
ACTUAL_HOME="/home/$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
MAKEMKV_USER_DIR="${ACTUAL_HOME}/.MakeMKV"
|
|
||||||
if [[ ! -d "$MAKEMKV_USER_DIR" ]]; then
|
|
||||||
mkdir -p "$MAKEMKV_USER_DIR"
|
|
||||||
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_USER_DIR"
|
|
||||||
else
|
|
||||||
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_USER_DIR"
|
|
||||||
fi
|
|
||||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$MAKEMKV_USER_DIR" 2>/dev/null || true
|
|
||||||
chmod 700 "$MAKEMKV_USER_DIR" 2>/dev/null || true
|
|
||||||
else
|
else
|
||||||
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
|
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
||||||
|
# Laufzeit-relevant ist das Verzeichnis des Service-Users.
|
||||||
|
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
||||||
|
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
||||||
|
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
||||||
|
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
||||||
|
else
|
||||||
|
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
||||||
|
fi
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
# --- Systemd-Dienst: Backend -------------------------------------------------
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
header "Systemd-Dienst (Backend) erstellen"
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
|
|
||||||
@@ -550,6 +576,10 @@ StartLimitIntervalSec=60
|
|||||||
StartLimitBurst=3
|
StartLimitBurst=3
|
||||||
|
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
|
Environment=HOME=${SERVICE_HOME}
|
||||||
|
Environment=LANG=C.UTF-8
|
||||||
|
Environment=LC_ALL=C.UTF-8
|
||||||
|
Environment=LANGUAGE=C.UTF-8
|
||||||
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
@@ -566,7 +596,7 @@ SupplementaryGroups=video render cdrom disk
|
|||||||
NoNewPrivileges=false
|
NoNewPrivileges=false
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -676,6 +706,11 @@ missing_tools=()
|
|||||||
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
||||||
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
||||||
command_exists mediainfo || missing_tools+=("mediainfo")
|
command_exists mediainfo || missing_tools+=("mediainfo")
|
||||||
|
command_exists cdparanoia || missing_tools+=("cdparanoia")
|
||||||
|
command_exists flac || missing_tools+=("flac")
|
||||||
|
command_exists lame || missing_tools+=("lame")
|
||||||
|
command_exists opusenc || missing_tools+=("opusenc")
|
||||||
|
command_exists oggenc || missing_tools+=("oggenc")
|
||||||
|
|
||||||
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||||
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
||||||
|
|||||||
Reference in New Issue
Block a user