0.11.0 Multi LW

This commit is contained in:
2026-03-17 19:58:32 +00:00
parent 16443c8a45
commit 16c783e1aa
25 changed files with 811 additions and 960 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.10.2-25",
"version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.10.2-25",
"version": "0.11.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.10.2-25",
"version": "0.11.0",
"private": true,
"type": "commonjs",
"scripts": {
+6
View File
@@ -962,6 +962,12 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerkspfade für den expliziten Modus als JSON-Array, z.B. [\"/dev/sr0\",\"/dev/sr1\"]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_devices', '[]')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('auto_eject_after_rip', 'Laufwerk', 'Laufwerk nach Rip auswerfen', 'boolean', 1, 'Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject).', 'false', '[]', '{}', 45)`
-18
View File
@@ -39,24 +39,6 @@ router.get(
})
);
router.get(
'/database',
asyncHandler(async (req, res) => {
logger.info('get:database', {
reqId: req.reqId,
status: req.query.status,
search: req.query.search
});
const rows = await historyService.getDatabaseRows({
status: req.query.status,
search: req.query.search
});
res.json({ rows });
})
);
router.get(
'/orphan-raw',
asyncHandler(async (req, res) => {
+12 -2
View File
@@ -29,12 +29,22 @@ router.get(
router.post(
'/analyze',
asyncHandler(async (req, res) => {
logger.info('post:analyze', { reqId: req.reqId });
const result = await pipelineService.analyzeDisc();
const devicePath = String(req.body?.devicePath || '').trim() || null;
logger.info('post:analyze', { reqId: req.reqId, devicePath });
const result = await pipelineService.analyzeDisc(devicePath);
res.json({ result });
})
);
router.get(
'/cd/drives',
asyncHandler(async (req, res) => {
logger.debug('get:cd:drives', { reqId: req.reqId });
const snapshot = pipelineService.getSnapshot();
res.json({ cdDrives: snapshot.cdDrives || {} });
})
);
router.post(
'/rescan-disc',
asyncHandler(async (req, res) => {
+7 -2
View File
@@ -503,6 +503,8 @@ async function ripAndEncode(options) {
}
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
const encodeResults = [];
if (format === 'wav') {
// Keep RAW WAVs in place and copy them to the final output structure.
for (let i = 0; i < tracksToRip.length; i++) {
@@ -532,8 +534,9 @@ async function ripAndEncode(options) {
percent: 50 + ((i + 1) / tracksToRip.length) * 50
});
log('info', `WAV für Track ${track.position} gespeichert.`);
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
}
return { outputDir, format, trackCount: tracksToRip.length };
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
}
for (let i = 0; i < tracksToRip.length; i++) {
@@ -578,6 +581,7 @@ async function ripAndEncode(options) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
throw new Error(
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
@@ -605,9 +609,10 @@ async function ripAndEncode(options) {
});
log('info', `Track ${track.position} encodiert.`);
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
}
return { outputDir, format, trackCount: tracksToRip.length };
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
}
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
+154 -9
View File
@@ -190,6 +190,7 @@ class DiskDetectionService extends EventEmitter {
this.timer = null;
this.lastDetected = null;
this.lastPresent = false;
this.detectedDiscs = new Map(); // devicePath → device object (multi-drive tracking)
this.deviceLocks = new Map();
this.pollingSuspended = false;
}
@@ -248,8 +249,8 @@ class DiskDetectionService extends EventEmitter {
if (this.pollingSuspended) {
logger.debug('poll:skip:suspended', { nextDelay });
} else if (autoDetectionEnabled) {
const detected = await this.detectDisc(map);
this.applyDetectionResult(detected, { forceInsertEvent: false });
const detectedList = await this.detectAllDiscs(map);
this.applyMultiDetectionResults(detectedList, { forceInsertEvent: false });
} else {
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
}
@@ -270,17 +271,30 @@ class DiskDetectionService extends EventEmitter {
driveDevice: map.drive_device
});
const detected = await this.detectDisc(map);
const result = this.applyDetectionResult(detected, { forceInsertEvent: true });
const detectedList = await this.detectAllDiscs(map);
const results = this.applyMultiDetectionResults(detectedList, { forceInsertEvent: true });
const insertedResults = results.filter((r) => r.emitted === 'discInserted');
const removedResults = results.filter((r) => r.emitted === 'discRemoved');
const present = detectedList.length > 0;
const firstInserted = insertedResults[0] || null;
logger.info('rescan:done', {
present: result.present,
emitted: result.emitted,
changed: result.changed,
detected: result.device || null
driveCount: detectedList.length,
inserted: insertedResults.length,
removed: removedResults.length
});
return result;
// Backward-compat return: report on first inserted device (or removed if nothing inserted)
return {
present,
changed: insertedResults.length > 0 || removedResults.length > 0,
emitted: insertedResults.length > 0
? 'discInserted'
: (removedResults.length > 0 ? 'discRemoved' : 'none'),
device: firstInserted?.device || null,
allDetected: detectedList
};
} catch (error) {
logger.error('rescan:error', { error: errorToMeta(error) });
throw error;
@@ -425,6 +439,83 @@ class DiskDetectionService extends EventEmitter {
return this.detectAuto();
}
// Returns ALL discs with media (not just the first one)
async detectAllDiscs(settingsMap) {
if (settingsMap.drive_mode === 'explicit') {
// drive_devices is a JSON array of paths; fall back to legacy drive_device
let explicitPaths = [];
try {
const parsed = JSON.parse(settingsMap.drive_devices || '[]');
if (Array.isArray(parsed)) {
explicitPaths = parsed.map((p) => String(p || '').trim()).filter(Boolean);
}
} catch (_error) {
// malformed JSON — ignore, fall through to legacy
}
if (explicitPaths.length === 0) {
const legacy = String(settingsMap.drive_device || '').trim();
if (legacy) {
explicitPaths = [legacy];
}
}
const results = await Promise.all(explicitPaths.map((p) => this.detectExplicit(p)));
return results.filter(Boolean);
}
return this.detectAllAuto();
}
// Multi-drive: tracks per-device state and emits insert/remove events for each
applyMultiDetectionResults(detectedList, { forceInsertEvent = false } = {}) {
const detected = Array.isArray(detectedList) ? detectedList : [];
const newMap = new Map();
for (const device of detected) {
if (device?.path) {
newMap.set(device.path, device);
}
}
const results = [];
// Check for new or changed devices
for (const [devicePath, device] of newMap) {
const previous = this.detectedDiscs.get(devicePath);
const changed = previous ? buildSignature(previous) !== buildSignature(device) : false;
const shouldEmitInserted = forceInsertEvent || !previous || changed;
if (shouldEmitInserted) {
logger.info('disc:inserted', { device, forceInsertEvent, changed });
this.emit('discInserted', device);
results.push({ path: devicePath, emitted: 'discInserted', device });
} else {
results.push({ path: devicePath, emitted: 'none', device });
}
}
// Check for removed devices
for (const [devicePath, device] of this.detectedDiscs) {
if (!newMap.has(devicePath)) {
logger.info('disc:removed', { device });
this.emit('discRemoved', device);
results.push({ path: devicePath, emitted: 'discRemoved', device: null });
}
}
this.detectedDiscs = newMap;
// Update legacy single-disc tracking for backward compat
if (newMap.size > 0) {
this.lastDetected = Array.from(newMap.values())[0];
this.lastPresent = true;
} else {
if (this.lastPresent) {
this.lastDetected = null;
this.lastPresent = false;
}
}
return results;
}
async detectExplicit(devicePath) {
if (this.isDeviceLocked(devicePath)) {
logger.debug('detect:explicit:locked', {
@@ -527,6 +618,60 @@ class DiskDetectionService extends EventEmitter {
return null;
}
async detectAllAuto() {
const details = await this.getBlockDeviceInfo();
const romCandidates = details.filter((entry) => entry.type === 'rom');
const results = [];
for (const item of romCandidates) {
const path = item.path || (item.name ? `/dev/${item.name}` : null);
if (!path) {
continue;
}
if (this.isDeviceLocked(path)) {
logger.debug('detect:all-auto:skip-locked', {
path,
activeLocks: this.getActiveLocks()
});
continue;
}
const mediaState = await this.checkMediaPresent(path);
if (!mediaState.hasMedia) {
continue;
}
const discLabel = await this.getDiscLabel(path);
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
const mediaProfile = await this.inferMediaProfile(path, {
discLabel,
label: item.label,
model: item.model,
fstype: detectedFsType,
mountpoint: item.mountpoint
});
const detected = {
mode: 'auto',
path,
name: item.name,
model: item.model || 'Optical Drive',
label: item.label || null,
discLabel: discLabel || null,
mountpoint: item.mountpoint || null,
fstype: detectedFsType,
mediaProfile: mediaProfile || null,
index: this.guessDiscIndex(item.name)
};
logger.debug('detect:all-auto:found', { detected });
results.push(detected);
}
logger.debug('detect:all-auto:done', { count: results.length });
return results;
}
async getBlockDeviceInfo() {
try {
const { stdout } = await execFileAsync('lsblk', [
+5 -9
View File
@@ -864,6 +864,10 @@ function buildJobDisplayTitle(job = null) {
return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-';
}
function isHiddenDirectoryName(value) {
return String(value || '').trim().startsWith('.');
}
function isFilesystemRootPath(inputPath) {
const raw = String(inputPath || '').trim();
if (!raw) {
@@ -1530,14 +1534,6 @@ class HistoryService {
};
}
async getDatabaseRows(filters = {}) {
const jobs = await this.getJobs(filters);
return jobs.map((job) => ({
...job,
rawFolderName: job.raw_path ? path.basename(job.raw_path) : null
}));
}
async getOrphanRawFolders() {
const settings = await settingsService.getSettingsMap();
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
@@ -1586,7 +1582,7 @@ class HistoryService {
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
if (!entry.isDirectory() || isHiddenDirectoryName(entry.name)) {
continue;
}
+282 -94
View File
@@ -3847,6 +3847,7 @@ class PipelineService extends EventEmitter {
context: {}
};
this.detectedDisc = null;
this.cdDrives = new Map(); // devicePath → per-drive CD state object
this.activeProcess = null;
this.activeProcesses = new Map();
this.cancelRequestedByJob = new Set();
@@ -4299,13 +4300,108 @@ class PipelineService extends EventEmitter {
for (const [id, data] of this.jobProgress) {
jobProgress[id] = data;
}
const cdDrives = {};
for (const [devicePath, driveState] of this.cdDrives) {
cdDrives[devicePath] = driveState;
}
return {
...this.snapshot,
jobProgress,
queue: this.lastQueueSnapshot
queue: this.lastQueueSnapshot,
cdDrives
};
}
_setCdDriveState(devicePath, updates) {
const existing = this.cdDrives.get(devicePath) || {
devicePath,
state: 'DISC_DETECTED',
jobId: null,
device: null,
progress: 0,
eta: null,
statusText: null,
context: {}
};
const next = {
...existing,
...updates,
devicePath
};
this.cdDrives.set(devicePath, next);
// Sync jobProgress so per-job queries work
const resolvedJobId = Number(next.jobId || 0);
if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) {
const prev = this.jobProgress.get(resolvedJobId) || {};
const contextPatch = next.context && typeof next.context === 'object' ? next.context : null;
const mergedContext = contextPatch
? { ...(prev.context || {}), ...contextPatch }
: (prev.context || null);
const nextJobProgress = {
...prev,
state: next.state,
progress: next.progress ?? prev.progress ?? 0,
eta: next.eta ?? null,
statusText: next.statusText ?? null
};
if (mergedContext && Object.keys(mergedContext).length > 0) {
nextJobProgress.context = mergedContext;
}
this.jobProgress.set(resolvedJobId, nextJobProgress);
}
// Manage disc-polling suspension while any CD drive is actively ripping
const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']);
const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state));
if (anyActive) {
diskDetectionService.suspendPolling();
} else {
diskDetectionService.resumePolling();
}
logger.info('cd:drive:state', { devicePath, state: next.state, jobId: next.jobId });
const snapshotPayload = this.getSnapshot();
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
this.emit('stateChanged', snapshotPayload);
void this.emitQueueChanged();
}
_removeCdDrive(devicePath) {
const drive = this.cdDrives.get(devicePath);
if (!drive) {
return;
}
const jobId = Number(drive.jobId || 0);
this.cdDrives.delete(devicePath);
if (Number.isFinite(jobId) && jobId > 0) {
this.jobProgress.delete(jobId);
}
// Resume polling if no other active drives
const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']);
const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state));
if (!anyActive) {
diskDetectionService.resumePolling();
}
logger.info('cd:drive:removed', { devicePath });
const snapshotPayload = this.getSnapshot();
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
this.emit('stateChanged', snapshotPayload);
}
_getCdDriveByJobId(jobId) {
const normalizedId = Number(jobId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return null;
}
for (const [devicePath, drive] of this.cdDrives) {
if (Number(drive.jobId) === normalizedId) {
return { ...drive, devicePath };
}
}
return null;
}
normalizeParallelJobsLimit(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value < 1) {
@@ -5207,21 +5303,39 @@ class PipelineService extends EventEmitter {
if (enabled !== 'true' && enabled !== '1') {
return;
}
const device = devicePath
|| (settingsMap?.drive_mode === 'explicit' ? String(settingsMap?.drive_device || '').trim() : null)
|| String(settingsMap?.drive_device || '').trim()
|| '/dev/sr0';
logger.info('eject:drive', { device });
await new Promise((resolve) => {
execFile('eject', [device], { timeout: 10000 }, (error) => {
if (error) {
logger.warn('eject:drive:failed', { device, error: errorToMeta(error) });
} else {
logger.info('eject:drive:ok', { device });
// Collect the list of drives to eject
const devicesToEject = [];
if (devicePath) {
devicesToEject.push(devicePath);
} else if (settingsMap?.drive_mode === 'explicit') {
try {
const parsed = JSON.parse(settingsMap?.drive_devices || '[]');
if (Array.isArray(parsed)) {
parsed.map((p) => String(p || '').trim()).filter(Boolean).forEach((p) => devicesToEject.push(p));
}
resolve();
} catch (_error) {
// ignore
}
if (devicesToEject.length === 0) {
const legacy = String(settingsMap?.drive_device || '').trim();
if (legacy) devicesToEject.push(legacy);
}
} else {
devicesToEject.push(String(settingsMap?.drive_device || '').trim() || '/dev/sr0');
}
for (const device of devicesToEject) {
logger.info('eject:drive', { device });
await new Promise((resolve) => {
execFile('eject', [device], { timeout: 10000 }, (error) => {
if (error) {
logger.warn('eject:drive:failed', { device, error: errorToMeta(error) });
} else {
logger.info('eject:drive:ok', { device });
}
resolve();
});
});
});
}
} catch (error) {
logger.warn('eject:drive:error', { error: errorToMeta(error) });
}
@@ -5459,17 +5573,28 @@ class PipelineService extends EventEmitter {
mediaProfile: resolvedMediaProfile
};
logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile });
wsService.broadcast('DISC_DETECTED', { device: resolvedDevice });
// CD discs are tracked per-drive in cdDrives, not in the global state machine
if (resolvedMediaProfile === 'cd') {
const cdDevicePath = String(resolvedDevice.path || '').trim();
if (cdDevicePath) {
const existingDrive = this.cdDrives.get(cdDevicePath);
const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']);
if (!ACTIVE_CD_STATES.has(existingDrive?.state)) {
this._setCdDriveState(cdDevicePath, { state: 'DISC_DETECTED', device: resolvedDevice });
}
}
return;
}
const previousDevice = this.snapshot.context?.device || this.detectedDisc;
const previousState = this.snapshot.state;
const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null;
const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false;
this.detectedDisc = resolvedDevice;
logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile });
wsService.broadcast('DISC_DETECTED', {
device: resolvedDevice
});
if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') {
const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`;
@@ -5517,9 +5642,20 @@ class PipelineService extends EventEmitter {
async onDiscRemoved(deviceInfo) {
logger.info('disc:removed', { deviceInfo });
wsService.broadcast('DISC_REMOVED', {
device: deviceInfo
});
wsService.broadcast('DISC_REMOVED', { device: deviceInfo });
const removedPath = String(deviceInfo?.path || '').trim();
// If it's a tracked CD drive, remove or leave it depending on active state
if (removedPath && this.cdDrives.has(removedPath)) {
const driveState = this.cdDrives.get(removedPath);
const ACTIVE_CD_STATES = new Set(['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']);
if (!ACTIVE_CD_STATES.has(driveState?.state)) {
this._removeCdDrive(removedPath);
}
// If actively ripping, leave the entry rip completion will clean it up
return;
}
this.detectedDisc = null;
if (this.snapshot.state === 'DISC_DETECTED') {
@@ -5865,11 +6001,25 @@ class PipelineService extends EventEmitter {
};
}
async analyzeDisc() {
async analyzeDisc(devicePath = null) {
this.ensureNotBusy('analyze');
logger.info('analyze:start');
logger.info('analyze:start', { devicePath });
let device;
if (devicePath) {
const driveEntry = this.cdDrives.get(devicePath);
device = driveEntry?.device || null;
if (!device && this.detectedDisc?.path === devicePath) {
device = this.detectedDisc;
}
if (!device) {
// Minimal device object — cdRipService only needs path
device = { path: devicePath, mediaProfile: 'cd' };
}
} else {
device = this.detectedDisc || this.snapshot.context?.device;
}
const device = this.detectedDisc || this.snapshot.context?.device;
if (!device) {
const error = new Error('Keine Disk erkannt.');
error.statusCode = 400;
@@ -11381,32 +11531,60 @@ class PipelineService extends EventEmitter {
)
: null;
await this.setState(finalState, {
activeJobId: jobId,
progress: this.snapshot.progress,
eta: null,
statusText: message,
context: {
const failContext = {
...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}),
jobId,
stage,
error: message,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile,
inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null,
selectedMetadata: resolvedSelectedMetadata,
...(isCdFailure ? {
tracks: resolvedTracks,
cdRipConfig: resolvedCdRipConfig,
cdLive: jobProgressContext?.cdLive || null,
devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null,
cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null
} : {}),
canRestartEncodeFromLastSettings: hasConfirmedPlan,
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
};
if (isCdFailure) {
const cdDevicePath = String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null;
if (cdDevicePath) {
this._setCdDriveState(cdDevicePath, {
state: finalState,
jobId,
progress: this.cdDrives.get(cdDevicePath)?.progress ?? 0,
eta: null,
statusText: message,
context: failContext
});
}
} else {
await this.setState(finalState, {
activeJobId: jobId,
progress: this.snapshot.progress,
eta: null,
statusText: message,
context: {
...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}),
jobId,
stage,
error: message,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile,
mediaProfile: resolvedMediaProfile,
inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null,
selectedMetadata: resolvedSelectedMetadata,
...(isCdFailure ? {
tracks: resolvedTracks,
cdRipConfig: resolvedCdRipConfig,
cdLive: jobProgressContext?.cdLive || null,
devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null,
cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null
} : {}),
canRestartEncodeFromLastSettings: hasConfirmedPlan,
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
}
});
}});
}
this.cancelRequestedByJob.delete(Number(jobId));
void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', {
@@ -12317,8 +12495,10 @@ class PipelineService extends EventEmitter {
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
// Read TOC
await this.setState('CD_ANALYZING', {
activeJobId: job.id,
this._setCdDriveState(devicePath, {
state: 'CD_ANALYZING',
jobId: job.id,
device,
progress: 0,
eta: null,
statusText: 'CD wird analysiert …',
@@ -12354,28 +12534,26 @@ class PipelineService extends EventEmitter {
`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
}
});
}
const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null;
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
this._setCdDriveState(devicePath, {
state: 'CD_METADATA_SELECTION',
jobId: job.id,
device,
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) {
@@ -12480,18 +12658,20 @@ class PipelineService extends EventEmitter {
`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,
const resolvedDevicePath = String(job?.disc_device || '').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`;
const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null;
if (resolvedDevicePath) {
this._setCdDriveState(resolvedDevicePath, {
state: 'CD_READY_TO_RIP',
jobId,
progress: 0,
eta: null,
statusText: 'CD bereit zum Rippen',
context: {
...(this.snapshot.context || {}),
...(existingDrive?.context || {}),
jobId,
mediaProfile: 'cd',
tracks: mergedTracks,
@@ -12702,8 +12882,7 @@ class PipelineService extends EventEmitter {
}
const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {};
const device = this.detectedDisc || this.snapshot.context?.device;
const devicePath = String(device?.path || activeJob.disc_device || '').trim();
const devicePath = String(activeJob.disc_device || '').trim();
if (!devicePath) {
const error = new Error('Kein CD-Laufwerk bekannt.');
@@ -12921,13 +13100,15 @@ class PipelineService extends EventEmitter {
encode_plan_json: JSON.stringify(cdEncodePlan)
});
await this.setState('CD_RIPPING', {
activeJobId,
const existingCdDrive = this.cdDrives.get(devicePath);
this._setCdDriveState(devicePath, {
state: 'CD_RIPPING',
jobId: activeJobId,
progress: 0,
eta: null,
statusText: 'CD wird gerippt …',
context: {
...(this.snapshot.context || {}),
...(existingCdDrive?.context || {}),
jobId: activeJobId,
mediaProfile: 'cd',
tracks: mergedTracks,
@@ -13107,7 +13288,7 @@ class PipelineService extends EventEmitter {
}
}
};
await cdRipService.ripAndEncode({
const { encodeResults: cdEncodeResults = [] } = await cdRipService.ripAndEncode({
jobId,
devicePath,
cdparanoiaCmd,
@@ -13189,6 +13370,17 @@ class PipelineService extends EventEmitter {
cdLive: buildLiveContext(null)
}
});
// Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates)
const currentCdDriveEntry = this.cdDrives.get(devicePath);
if (currentCdDriveEntry) {
this.cdDrives.set(devicePath, {
...currentCdDriveEntry,
state: stage,
progress: clampedPercent,
statusText,
context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) }
});
}
},
onLog: async (level, msg) => {
await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
@@ -13249,6 +13441,7 @@ class PipelineService extends EventEmitter {
output_path: outputDir,
handbrake_info_json: JSON.stringify({
mode: 'cd_rip',
tracks: cdEncodeResults,
preEncodeScripts: preEncodeScriptsSummary,
postEncodeScripts: postEncodeScriptsSummary
})
@@ -13273,8 +13466,9 @@ class PipelineService extends EventEmitter {
currentTrackPosition = null;
const finishedCdLive = buildLiveContext(null);
await this.setState('FINISHED', {
activeJobId: jobId,
this._setCdDriveState(devicePath, {
state: 'FINISHED',
jobId,
progress: 100,
eta: null,
statusText: finishedStatusText,
@@ -13298,20 +13492,14 @@ class PipelineService extends EventEmitter {
} catch (error) {
settleLifecycle();
const failedCdLive = buildLiveContext(currentTrackPosition || null);
await this.updateProgress(
this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING',
this.snapshot.progress,
null,
this.snapshot.statusText,
processKey,
{
contextPatch: {
cdLive: failedCdLive
}
}
);
const currentDriveEntry = this.cdDrives.get(devicePath);
const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING';
const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING';
await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, {
contextPatch: { cdLive: failedCdLive }
});
logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) });
await this.failJob(jobId, this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING', error);
await this.failJob(jobId, failStage, error);
} finally {
this.activeProcesses.delete(processKey);
this.syncPrimaryActiveProcess();
+24 -4
View File
@@ -1192,11 +1192,21 @@ class SettingsService {
resolveHandBrakeSourceArg(map, deviceInfo = null) {
if (map.drive_mode === 'explicit') {
const device = String(map.drive_device || '').trim();
if (!device) {
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
// Prefer drive_devices array, fall back to legacy drive_device
let explicitPaths = [];
try {
const parsed = JSON.parse(map.drive_devices || '[]');
if (Array.isArray(parsed)) {
explicitPaths = parsed.map((p) => String(p || '').trim()).filter(Boolean);
}
} catch (_error) {
// ignore parse error
}
return device;
const firstExplicit = explicitPaths[0] || String(map.drive_device || '').trim();
if (!firstExplicit) {
throw new Error('Kein Laufwerk konfiguriert, obwohl drive_mode=explicit gesetzt ist.');
}
return firstExplicit;
}
const detectedPath = String(deviceInfo?.path || '').trim();
@@ -1204,6 +1214,16 @@ class SettingsService {
return detectedPath;
}
// Fallback: first explicit device, or legacy drive_device
try {
const parsed = JSON.parse(map.drive_devices || '[]');
if (Array.isArray(parsed) && parsed.length > 0) {
const first = String(parsed[0] || '').trim();
if (first) return first;
}
} catch (_error) {
// ignore
}
const configuredPath = String(map.drive_device || '').trim();
if (configuredPath) {
return configuredPath;