0.11.0 Multi LW
This commit is contained in:
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.10.2-25",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user