0.11.0 Multi LW
This commit is contained in:
@@ -9,7 +9,13 @@
|
|||||||
"Read(//mnt/external/media/**)",
|
"Read(//mnt/external/media/**)",
|
||||||
"WebFetch(domain:www.makemkv.com)",
|
"WebFetch(domain:www.makemkv.com)",
|
||||||
"Bash(node --check backend/src/services/pipelineService.js)",
|
"Bash(node --check backend/src/services/pipelineService.js)",
|
||||||
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)"
|
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)",
|
||||||
|
"Bash(find /home/michael/ripster -name *.db)",
|
||||||
|
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")",
|
||||||
|
"Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)",
|
||||||
|
"Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)",
|
||||||
|
"Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)",
|
||||||
|
"Bash(node --check /home/michael/ripster/backend/src/db/database.js)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"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_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(
|
await db.run(
|
||||||
`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 ('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)`
|
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(
|
router.get(
|
||||||
'/orphan-raw',
|
'/orphan-raw',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -29,12 +29,22 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
'/analyze',
|
'/analyze',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
logger.info('post:analyze', { reqId: req.reqId });
|
const devicePath = String(req.body?.devicePath || '').trim() || null;
|
||||||
const result = await pipelineService.analyzeDisc();
|
logger.info('post:analyze', { reqId: req.reqId, devicePath });
|
||||||
|
const result = await pipelineService.analyzeDisc(devicePath);
|
||||||
res.json({ result });
|
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(
|
router.post(
|
||||||
'/rescan-disc',
|
'/rescan-disc',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ async function ripAndEncode(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
|
const encodeResults = [];
|
||||||
|
|
||||||
if (format === 'wav') {
|
if (format === 'wav') {
|
||||||
// Keep RAW WAVs in place and copy them to the final output structure.
|
// Keep RAW WAVs in place and copy them to the final output structure.
|
||||||
for (let i = 0; i < tracksToRip.length; i++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
@@ -532,8 +534,9 @@ async function ripAndEncode(options) {
|
|||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||||
});
|
});
|
||||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
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++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
@@ -578,6 +581,7 @@ async function ripAndEncode(options) {
|
|||||||
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(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.`);
|
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) {
|
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.lastDetected = null;
|
this.lastDetected = null;
|
||||||
this.lastPresent = false;
|
this.lastPresent = false;
|
||||||
|
this.detectedDiscs = new Map(); // devicePath → device object (multi-drive tracking)
|
||||||
this.deviceLocks = new Map();
|
this.deviceLocks = new Map();
|
||||||
this.pollingSuspended = false;
|
this.pollingSuspended = false;
|
||||||
}
|
}
|
||||||
@@ -248,8 +249,8 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (this.pollingSuspended) {
|
if (this.pollingSuspended) {
|
||||||
logger.debug('poll:skip:suspended', { nextDelay });
|
logger.debug('poll:skip:suspended', { nextDelay });
|
||||||
} else if (autoDetectionEnabled) {
|
} else if (autoDetectionEnabled) {
|
||||||
const detected = await this.detectDisc(map);
|
const detectedList = await this.detectAllDiscs(map);
|
||||||
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
this.applyMultiDetectionResults(detectedList, { forceInsertEvent: false });
|
||||||
} else {
|
} else {
|
||||||
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
|
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
|
||||||
}
|
}
|
||||||
@@ -270,17 +271,30 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
driveDevice: map.drive_device
|
driveDevice: map.drive_device
|
||||||
});
|
});
|
||||||
|
|
||||||
const detected = await this.detectDisc(map);
|
const detectedList = await this.detectAllDiscs(map);
|
||||||
const result = this.applyDetectionResult(detected, { forceInsertEvent: true });
|
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', {
|
logger.info('rescan:done', {
|
||||||
present: result.present,
|
driveCount: detectedList.length,
|
||||||
emitted: result.emitted,
|
inserted: insertedResults.length,
|
||||||
changed: result.changed,
|
removed: removedResults.length
|
||||||
detected: result.device || null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
logger.error('rescan:error', { error: errorToMeta(error) });
|
logger.error('rescan:error', { error: errorToMeta(error) });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -425,6 +439,83 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return this.detectAuto();
|
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) {
|
async detectExplicit(devicePath) {
|
||||||
if (this.isDeviceLocked(devicePath)) {
|
if (this.isDeviceLocked(devicePath)) {
|
||||||
logger.debug('detect:explicit:locked', {
|
logger.debug('detect:explicit:locked', {
|
||||||
@@ -527,6 +618,60 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return null;
|
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() {
|
async getBlockDeviceInfo() {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('lsblk', [
|
const { stdout } = await execFileAsync('lsblk', [
|
||||||
|
|||||||
@@ -864,6 +864,10 @@ function buildJobDisplayTitle(job = null) {
|
|||||||
return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-';
|
return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHiddenDirectoryName(value) {
|
||||||
|
return String(value || '').trim().startsWith('.');
|
||||||
|
}
|
||||||
|
|
||||||
function isFilesystemRootPath(inputPath) {
|
function isFilesystemRootPath(inputPath) {
|
||||||
const raw = String(inputPath || '').trim();
|
const raw = String(inputPath || '').trim();
|
||||||
if (!raw) {
|
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() {
|
async getOrphanRawFolders() {
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
||||||
@@ -1586,7 +1582,7 @@ class HistoryService {
|
|||||||
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const entry of dirEntries) {
|
for (const entry of dirEntries) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory() || isHiddenDirectoryName(entry.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3847,6 +3847,7 @@ class PipelineService extends EventEmitter {
|
|||||||
context: {}
|
context: {}
|
||||||
};
|
};
|
||||||
this.detectedDisc = null;
|
this.detectedDisc = null;
|
||||||
|
this.cdDrives = new Map(); // devicePath → per-drive CD state object
|
||||||
this.activeProcess = null;
|
this.activeProcess = null;
|
||||||
this.activeProcesses = new Map();
|
this.activeProcesses = new Map();
|
||||||
this.cancelRequestedByJob = new Set();
|
this.cancelRequestedByJob = new Set();
|
||||||
@@ -4299,13 +4300,108 @@ class PipelineService extends EventEmitter {
|
|||||||
for (const [id, data] of this.jobProgress) {
|
for (const [id, data] of this.jobProgress) {
|
||||||
jobProgress[id] = data;
|
jobProgress[id] = data;
|
||||||
}
|
}
|
||||||
|
const cdDrives = {};
|
||||||
|
for (const [devicePath, driveState] of this.cdDrives) {
|
||||||
|
cdDrives[devicePath] = driveState;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
jobProgress,
|
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) {
|
normalizeParallelJobsLimit(rawValue) {
|
||||||
const value = Number(rawValue);
|
const value = Number(rawValue);
|
||||||
if (!Number.isFinite(value) || value < 1) {
|
if (!Number.isFinite(value) || value < 1) {
|
||||||
@@ -5207,21 +5303,39 @@ class PipelineService extends EventEmitter {
|
|||||||
if (enabled !== 'true' && enabled !== '1') {
|
if (enabled !== 'true' && enabled !== '1') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const device = devicePath
|
// Collect the list of drives to eject
|
||||||
|| (settingsMap?.drive_mode === 'explicit' ? String(settingsMap?.drive_device || '').trim() : null)
|
const devicesToEject = [];
|
||||||
|| String(settingsMap?.drive_device || '').trim()
|
if (devicePath) {
|
||||||
|| '/dev/sr0';
|
devicesToEject.push(devicePath);
|
||||||
logger.info('eject:drive', { device });
|
} else if (settingsMap?.drive_mode === 'explicit') {
|
||||||
await new Promise((resolve) => {
|
try {
|
||||||
execFile('eject', [device], { timeout: 10000 }, (error) => {
|
const parsed = JSON.parse(settingsMap?.drive_devices || '[]');
|
||||||
if (error) {
|
if (Array.isArray(parsed)) {
|
||||||
logger.warn('eject:drive:failed', { device, error: errorToMeta(error) });
|
parsed.map((p) => String(p || '').trim()).filter(Boolean).forEach((p) => devicesToEject.push(p));
|
||||||
} else {
|
|
||||||
logger.info('eject:drive:ok', { device });
|
|
||||||
}
|
}
|
||||||
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) {
|
} catch (error) {
|
||||||
logger.warn('eject:drive:error', { error: errorToMeta(error) });
|
logger.warn('eject:drive:error', { error: errorToMeta(error) });
|
||||||
}
|
}
|
||||||
@@ -5459,17 +5573,28 @@ class PipelineService extends EventEmitter {
|
|||||||
mediaProfile: resolvedMediaProfile
|
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 previousDevice = this.snapshot.context?.device || this.detectedDisc;
|
||||||
const previousState = this.snapshot.state;
|
const previousState = this.snapshot.state;
|
||||||
const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null;
|
const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null;
|
||||||
const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false;
|
const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false;
|
||||||
|
|
||||||
this.detectedDisc = resolvedDevice;
|
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') {
|
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.`;
|
const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`;
|
||||||
@@ -5517,9 +5642,20 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
async onDiscRemoved(deviceInfo) {
|
async onDiscRemoved(deviceInfo) {
|
||||||
logger.info('disc:removed', { deviceInfo });
|
logger.info('disc:removed', { deviceInfo });
|
||||||
wsService.broadcast('DISC_REMOVED', {
|
wsService.broadcast('DISC_REMOVED', { device: deviceInfo });
|
||||||
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;
|
this.detectedDisc = null;
|
||||||
if (this.snapshot.state === 'DISC_DETECTED') {
|
if (this.snapshot.state === 'DISC_DETECTED') {
|
||||||
@@ -5865,11 +6001,25 @@ class PipelineService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async analyzeDisc() {
|
async analyzeDisc(devicePath = null) {
|
||||||
this.ensureNotBusy('analyze');
|
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) {
|
if (!device) {
|
||||||
const error = new Error('Keine Disk erkannt.');
|
const error = new Error('Keine Disk erkannt.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -11381,32 +11531,60 @@ class PipelineService extends EventEmitter {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await this.setState(finalState, {
|
const failContext = {
|
||||||
activeJobId: jobId,
|
...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}),
|
||||||
progress: this.snapshot.progress,
|
jobId,
|
||||||
eta: null,
|
stage,
|
||||||
statusText: message,
|
error: message,
|
||||||
context: {
|
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 : {}),
|
...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}),
|
||||||
jobId,
|
jobId,
|
||||||
stage,
|
stage,
|
||||||
error: message,
|
error: message,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
outputPath: job?.output_path || null,
|
outputPath: job?.output_path || null,
|
||||||
mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile,
|
mediaProfile: resolvedMediaProfile,
|
||||||
inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null,
|
inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null,
|
||||||
selectedMetadata: resolvedSelectedMetadata,
|
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,
|
canRestartEncodeFromLastSettings: hasConfirmedPlan,
|
||||||
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
|
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
|
||||||
}
|
}});
|
||||||
});
|
}
|
||||||
|
|
||||||
this.cancelRequestedByJob.delete(Number(jobId));
|
this.cancelRequestedByJob.delete(Number(jobId));
|
||||||
|
|
||||||
void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', {
|
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';
|
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
|
|
||||||
// Read TOC
|
// Read TOC
|
||||||
await this.setState('CD_ANALYZING', {
|
this._setCdDriveState(devicePath, {
|
||||||
activeJobId: job.id,
|
state: 'CD_ANALYZING',
|
||||||
|
jobId: job.id,
|
||||||
|
device,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: 'CD wird analysiert …',
|
statusText: 'CD wird analysiert …',
|
||||||
@@ -12354,28 +12534,26 @@ class PipelineService extends EventEmitter {
|
|||||||
`CD analysiert: ${tracks.length} Track(s) gefunden.`
|
`CD analysiert: ${tracks.length} Track(s) gefunden.`
|
||||||
);
|
);
|
||||||
|
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null;
|
||||||
const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id));
|
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
|
||||||
if (!foreignRunningJobs.length) {
|
this._setCdDriveState(devicePath, {
|
||||||
const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null;
|
state: 'CD_METADATA_SELECTION',
|
||||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
|
jobId: job.id,
|
||||||
await this.setState('CD_METADATA_SELECTION', {
|
device,
|
||||||
activeJobId: job.id,
|
progress: 0,
|
||||||
progress: 0,
|
eta: null,
|
||||||
eta: null,
|
statusText: 'CD-Metadaten auswählen',
|
||||||
statusText: 'CD-Metadaten auswählen',
|
context: {
|
||||||
context: {
|
jobId: job.id,
|
||||||
jobId: job.id,
|
device,
|
||||||
device,
|
mediaProfile: 'cd',
|
||||||
mediaProfile: 'cd',
|
devicePath,
|
||||||
devicePath,
|
cdparanoiaCmd,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCommandPreview,
|
||||||
cdparanoiaCommandPreview,
|
detectedTitle,
|
||||||
detectedTitle,
|
tracks
|
||||||
tracks
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { jobId: job.id, detectedTitle, tracks };
|
return { jobId: job.id, detectedTitle, tracks };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -12480,18 +12658,20 @@ class PipelineService extends EventEmitter {
|
|||||||
`Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).`
|
`Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.isPrimaryJob(jobId)) {
|
const resolvedDevicePath = String(job?.disc_device || '').trim() || null;
|
||||||
const resolvedDevicePath = String(job?.disc_device || this.snapshot?.context?.device?.path || '').trim() || null;
|
const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null;
|
||||||
const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null;
|
const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
|
||||||
const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
|
const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null;
|
||||||
await this.setState('CD_READY_TO_RIP', {
|
if (resolvedDevicePath) {
|
||||||
activeJobId: jobId,
|
this._setCdDriveState(resolvedDevicePath, {
|
||||||
|
state: 'CD_READY_TO_RIP',
|
||||||
|
jobId,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: 'CD bereit zum Rippen',
|
statusText: 'CD bereit zum Rippen',
|
||||||
context: {
|
context: {
|
||||||
...(this.snapshot.context || {}),
|
...(existingDrive?.context || {}),
|
||||||
jobId,
|
jobId,
|
||||||
mediaProfile: 'cd',
|
mediaProfile: 'cd',
|
||||||
tracks: mergedTracks,
|
tracks: mergedTracks,
|
||||||
@@ -12702,8 +12882,7 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {};
|
const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {};
|
||||||
const device = this.detectedDisc || this.snapshot.context?.device;
|
const devicePath = String(activeJob.disc_device || '').trim();
|
||||||
const devicePath = String(device?.path || activeJob.disc_device || '').trim();
|
|
||||||
|
|
||||||
if (!devicePath) {
|
if (!devicePath) {
|
||||||
const error = new Error('Kein CD-Laufwerk bekannt.');
|
const error = new Error('Kein CD-Laufwerk bekannt.');
|
||||||
@@ -12921,13 +13100,15 @@ class PipelineService extends EventEmitter {
|
|||||||
encode_plan_json: JSON.stringify(cdEncodePlan)
|
encode_plan_json: JSON.stringify(cdEncodePlan)
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.setState('CD_RIPPING', {
|
const existingCdDrive = this.cdDrives.get(devicePath);
|
||||||
activeJobId,
|
this._setCdDriveState(devicePath, {
|
||||||
|
state: 'CD_RIPPING',
|
||||||
|
jobId: activeJobId,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: 'CD wird gerippt …',
|
statusText: 'CD wird gerippt …',
|
||||||
context: {
|
context: {
|
||||||
...(this.snapshot.context || {}),
|
...(existingCdDrive?.context || {}),
|
||||||
jobId: activeJobId,
|
jobId: activeJobId,
|
||||||
mediaProfile: 'cd',
|
mediaProfile: 'cd',
|
||||||
tracks: mergedTracks,
|
tracks: mergedTracks,
|
||||||
@@ -13107,7 +13288,7 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await cdRipService.ripAndEncode({
|
const { encodeResults: cdEncodeResults = [] } = await cdRipService.ripAndEncode({
|
||||||
jobId,
|
jobId,
|
||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
@@ -13189,6 +13370,17 @@ class PipelineService extends EventEmitter {
|
|||||||
cdLive: buildLiveContext(null)
|
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) => {
|
onLog: async (level, msg) => {
|
||||||
await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
|
await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
|
||||||
@@ -13249,6 +13441,7 @@ class PipelineService extends EventEmitter {
|
|||||||
output_path: outputDir,
|
output_path: outputDir,
|
||||||
handbrake_info_json: JSON.stringify({
|
handbrake_info_json: JSON.stringify({
|
||||||
mode: 'cd_rip',
|
mode: 'cd_rip',
|
||||||
|
tracks: cdEncodeResults,
|
||||||
preEncodeScripts: preEncodeScriptsSummary,
|
preEncodeScripts: preEncodeScriptsSummary,
|
||||||
postEncodeScripts: postEncodeScriptsSummary
|
postEncodeScripts: postEncodeScriptsSummary
|
||||||
})
|
})
|
||||||
@@ -13273,8 +13466,9 @@ class PipelineService extends EventEmitter {
|
|||||||
currentTrackPosition = null;
|
currentTrackPosition = null;
|
||||||
const finishedCdLive = buildLiveContext(null);
|
const finishedCdLive = buildLiveContext(null);
|
||||||
|
|
||||||
await this.setState('FINISHED', {
|
this._setCdDriveState(devicePath, {
|
||||||
activeJobId: jobId,
|
state: 'FINISHED',
|
||||||
|
jobId,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: finishedStatusText,
|
statusText: finishedStatusText,
|
||||||
@@ -13298,20 +13492,14 @@ class PipelineService extends EventEmitter {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
settleLifecycle();
|
settleLifecycle();
|
||||||
const failedCdLive = buildLiveContext(currentTrackPosition || null);
|
const failedCdLive = buildLiveContext(currentTrackPosition || null);
|
||||||
await this.updateProgress(
|
const currentDriveEntry = this.cdDrives.get(devicePath);
|
||||||
this.snapshot.state === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING',
|
const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING';
|
||||||
this.snapshot.progress,
|
const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING';
|
||||||
null,
|
await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, {
|
||||||
this.snapshot.statusText,
|
contextPatch: { cdLive: failedCdLive }
|
||||||
processKey,
|
});
|
||||||
{
|
|
||||||
contextPatch: {
|
|
||||||
cdLive: failedCdLive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) });
|
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 {
|
} finally {
|
||||||
this.activeProcesses.delete(processKey);
|
this.activeProcesses.delete(processKey);
|
||||||
this.syncPrimaryActiveProcess();
|
this.syncPrimaryActiveProcess();
|
||||||
|
|||||||
@@ -1192,11 +1192,21 @@ class SettingsService {
|
|||||||
|
|
||||||
resolveHandBrakeSourceArg(map, deviceInfo = null) {
|
resolveHandBrakeSourceArg(map, deviceInfo = null) {
|
||||||
if (map.drive_mode === 'explicit') {
|
if (map.drive_mode === 'explicit') {
|
||||||
const device = String(map.drive_device || '').trim();
|
// Prefer drive_devices array, fall back to legacy drive_device
|
||||||
if (!device) {
|
let explicitPaths = [];
|
||||||
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
|
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();
|
const detectedPath = String(deviceInfo?.path || '').trim();
|
||||||
@@ -1204,6 +1214,16 @@ class SettingsService {
|
|||||||
return detectedPath;
|
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();
|
const configuredPath = String(map.drive_device || '').trim();
|
||||||
if (configuredPath) {
|
if (configuredPath) {
|
||||||
return configuredPath;
|
return configuredPath;
|
||||||
|
|||||||
@@ -82,26 +82,6 @@ Liefert Job-Detail.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GET /api/history/database
|
|
||||||
|
|
||||||
Debug-Ansicht der DB-Zeilen (angereichert).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"status": "FINISHED",
|
|
||||||
"rawFolderName": "Inception - RAW - job-42"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GET /api/history/orphan-raw
|
## GET /api/history/orphan-raw
|
||||||
|
|
||||||
Sucht RAW-Ordner ohne zugehörigen Job.
|
Sucht RAW-Ordner ohne zugehörigen Job.
|
||||||
|
|||||||
+1
-11
@@ -9,17 +9,7 @@
|
|||||||
|
|
||||||
## Bereiche
|
## Bereiche
|
||||||
|
|
||||||
### 1) `Historie & Datenbank`
|
### `Gefundene RAW-Einträge`
|
||||||
|
|
||||||
Tabellarische Jobansicht mit:
|
|
||||||
|
|
||||||
- ID, Poster, Medium, Titel
|
|
||||||
- Status
|
|
||||||
- Start/Ende
|
|
||||||
|
|
||||||
Aktionen im Detaildialog entsprechen weitgehend der Seite `Historie` (inkl. Re-Encode, Review-Neustart, OMDb-Zuordnung, Dateilöschung).
|
|
||||||
|
|
||||||
### 2) `RAW ohne Historie`
|
|
||||||
|
|
||||||
Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben.
|
Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben.
|
||||||
|
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ In `Historie` -> Detaildialog:
|
|||||||
|
|
||||||
### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session
|
### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session
|
||||||
|
|
||||||
- in `Historie` oder `Database`: `Im Dashboard öffnen`
|
- in `Historie`: `Im Dashboard öffnen`
|
||||||
- im Dashboard Review erneut prüfen und starten
|
- im Dashboard Review erneut prüfen und starten
|
||||||
|
|
||||||
### Fall C: RAW ohne Historieneintrag
|
### Fall C: RAW ohne Historieneintrag
|
||||||
|
|
||||||
- `/database` öffnen
|
- `/database` öffnen
|
||||||
- Bereich `RAW ohne Historie`
|
- Bereich `Gefundene RAW-Einträge`
|
||||||
- `Job anlegen`
|
- `Job anlegen`
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+31
-1
@@ -110,6 +110,7 @@ function App() {
|
|||||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
|
const [expertMode, setExpertMode] = useState(false);
|
||||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||||
@@ -232,6 +233,13 @@ function App() {
|
|||||||
setDownloadSummary(response?.summary || null);
|
setDownloadSummary(response?.summary || null);
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
api.getSettings()
|
||||||
|
.then((response) => {
|
||||||
|
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||||
|
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||||
|
setExpertMode(val === 'true' || val === true);
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
@@ -307,6 +315,27 @@ function App() {
|
|||||||
setHardwareMonitoring(message.payload || null);
|
setHardwareMonitoring(message.payload || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SETTINGS_UPDATED') {
|
||||||
|
const setting = message.payload;
|
||||||
|
if (setting?.key === 'ui_expert_mode') {
|
||||||
|
const val = setting?.value;
|
||||||
|
setExpertMode(val === 'true' || val === true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SETTINGS_BULK_UPDATED') {
|
||||||
|
const keys = message.payload?.keys || [];
|
||||||
|
if (keys.includes('ui_expert_mode')) {
|
||||||
|
api.getSettings({ forceRefresh: true })
|
||||||
|
.then((response) => {
|
||||||
|
const allSettings = (response?.categories || []).flatMap((c) => c.settings || []);
|
||||||
|
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
||||||
|
setExpertMode(val === 'true' || val === true);
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'DOWNLOADS_UPDATED') {
|
if (message.type === 'DOWNLOADS_UPDATED') {
|
||||||
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
||||||
? message.payload.summary
|
? message.payload.summary
|
||||||
@@ -346,7 +375,8 @@ function App() {
|
|||||||
{ label: 'Dashboard', path: '/' },
|
{ label: 'Dashboard', path: '/' },
|
||||||
{ label: 'Settings', path: '/settings' },
|
{ label: 'Settings', path: '/settings' },
|
||||||
{ label: 'Historie', path: '/history' },
|
{ label: 'Historie', path: '/history' },
|
||||||
{ label: 'Downloads', path: '/downloads' }
|
{ label: 'Downloads', path: '/downloads' },
|
||||||
|
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
|
||||||
];
|
];
|
||||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||||
|
|||||||
@@ -470,9 +470,11 @@ export const api = {
|
|||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async analyzeDisc() {
|
async analyzeDisc(devicePath = null) {
|
||||||
|
const body = devicePath ? { devicePath } : {};
|
||||||
const result = await request('/pipeline/analyze', {
|
const result = await request('/pipeline/analyze', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
@@ -645,13 +647,6 @@ export const api = {
|
|||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
return request(`/history${suffix}`);
|
return request(`/history${suffix}`);
|
||||||
},
|
},
|
||||||
getDatabaseRows(params = {}) {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.status) query.set('status', params.status);
|
|
||||||
if (params.search) query.set('search', params.search);
|
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
||||||
return request(`/history/database${suffix}`);
|
|
||||||
},
|
|
||||||
getOrphanRawFolders() {
|
getOrphanRawFolders() {
|
||||||
return request('/history/orphan-raw');
|
return request('/history/orphan-raw');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { InputNumber } from 'primereact/inputnumber';
|
|||||||
import { InputSwitch } from 'primereact/inputswitch';
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
@@ -87,6 +88,64 @@ function toBoolean(value) {
|
|||||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DriveDevicesEditor({ value, onChange, settingKey }) {
|
||||||
|
const parseDevices = (val) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(val || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||||
|
} catch (_e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const devices = parseDevices(value);
|
||||||
|
|
||||||
|
const updateDevices = (newDevices) => {
|
||||||
|
onChange?.(settingKey, JSON.stringify(newDevices));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drive-devices-editor">
|
||||||
|
{devices.length === 0 && (
|
||||||
|
<p className="drive-devices-empty">Keine expliziten Laufwerke konfiguriert.</p>
|
||||||
|
)}
|
||||||
|
{devices.map((path, idx) => (
|
||||||
|
<div key={idx} className="drive-device-row">
|
||||||
|
<InputText
|
||||||
|
value={path}
|
||||||
|
placeholder="/dev/sr0"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...devices];
|
||||||
|
next[idx] = e.target.value;
|
||||||
|
updateDevices(next);
|
||||||
|
}}
|
||||||
|
className="drive-device-input"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateDevices(devices.filter((_, i) => i !== idx))}
|
||||||
|
type="button"
|
||||||
|
aria-label="Laufwerk entfernen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Laufwerk hinzufügen"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateDevices([...devices, ''])}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
||||||
const key = normalizeSettingKey(settingKey);
|
const key = normalizeSettingKey(settingKey);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -245,7 +304,7 @@ function SettingField({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{setting.type === 'string' || setting.type === 'path' ? (
|
{(setting.type === 'string' || setting.type === 'path') && setting.key !== 'drive_devices' ? (
|
||||||
<InputText
|
<InputText
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
@@ -253,6 +312,14 @@ function SettingField({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{setting.key === 'drive_devices' ? (
|
||||||
|
<DriveDevicesEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
settingKey={setting.key}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{setting.type === 'number' ? (
|
{setting.type === 'number' ? (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
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 } from '../utils/statusPresentation';
|
import { getStatusLabel } from '../utils/statusPresentation';
|
||||||
import { api } from '../api/client';
|
|
||||||
|
|
||||||
const CD_FORMAT_LABELS = {
|
const CD_FORMAT_LABELS = {
|
||||||
flac: 'FLAC',
|
flac: 'FLAC',
|
||||||
@@ -529,15 +527,6 @@ export default function JobDetailDialog({
|
|||||||
deleteEntryBusy = false,
|
deleteEntryBusy = false,
|
||||||
downloadBusyTarget = null
|
downloadBusyTarget = null
|
||||||
}) {
|
}) {
|
||||||
const [expertMode, setExpertMode] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
api.getSettings().then((res) => {
|
|
||||||
const allSettings = (res?.categories || []).flatMap((c) => c.settings || []);
|
|
||||||
const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value;
|
|
||||||
setExpertMode(val === 'true' || val === true);
|
|
||||||
}).catch(() => { });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||||
const showFinalLog = !running;
|
const showFinalLog = !running;
|
||||||
@@ -825,13 +814,6 @@ export default function JobDetailDialog({
|
|||||||
{job.error_message ? (
|
{job.error_message ? (
|
||||||
<div><strong>Fehler:</strong> {job.error_message}</div>
|
<div><strong>Fehler:</strong> {job.error_message}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{expertMode ? (
|
|
||||||
<div>
|
|
||||||
<Link to={`/database?table=jobs&id=${job.id}`} className="job-db-link" onClick={onHide}>
|
|
||||||
<i className="pi pi-database" /> DB-Eintrag ansehen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -948,6 +930,10 @@ export default function JobDetailDialog({
|
|||||||
const selectedSet = Array.isArray(job.encodePlan?.selectedTracks) && job.encodePlan.selectedTracks.length > 0
|
const selectedSet = Array.isArray(job.encodePlan?.selectedTracks) && job.encodePlan.selectedTracks.length > 0
|
||||||
? new Set(job.encodePlan.selectedTracks.map(Number))
|
? new Set(job.encodePlan.selectedTracks.map(Number))
|
||||||
: null;
|
: null;
|
||||||
|
const encodeResultMap = new Map(
|
||||||
|
(Array.isArray(job.handbrakeInfo?.tracks) ? job.handbrakeInfo.tracks : [])
|
||||||
|
.map((r) => [Number(r.position), r])
|
||||||
|
);
|
||||||
if (tracksRaw.length === 0) return <p className="job-meta-subtle">Keine Tracks vorhanden.</p>;
|
if (tracksRaw.length === 0) return <p className="job-meta-subtle">Keine Tracks vorhanden.</p>;
|
||||||
return (
|
return (
|
||||||
<div className="track-group">
|
<div className="track-group">
|
||||||
@@ -962,11 +948,13 @@ export default function JobDetailDialog({
|
|||||||
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
|
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
|
||||||
: '-';
|
: '-';
|
||||||
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
||||||
const ripDone = job?.ripSuccessful != null;
|
const encodeResult = encodeResultMap.get(pos);
|
||||||
const encodeLabel = !selected ? 'Nicht übernommen'
|
const encodeLabel = !selected ? 'Nicht übernommen'
|
||||||
: ripDone ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler')
|
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
|
||||||
: 'Übernommen';
|
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
|
||||||
const encodeClass = !selected ? '' : ripDone ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '';
|
const encodeClass = !selected ? ''
|
||||||
|
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
|
||||||
|
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
|
||||||
return (
|
return (
|
||||||
<div key={pos} className="track-item">
|
<div key={pos} className="track-item">
|
||||||
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
||||||
|
|||||||
@@ -1036,23 +1036,23 @@ 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
|
// Auto-open CD metadata dialog when any drive enters CD_METADATA_SELECTION
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentState = String(pipeline?.state || '').trim().toUpperCase();
|
const cdDrives = pipeline?.cdDrives;
|
||||||
if (currentState === 'CD_METADATA_SELECTION') {
|
if (!cdDrives || typeof cdDrives !== 'object') return;
|
||||||
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
for (const drive of Object.values(cdDrives)) {
|
||||||
if (ctx?.jobId && !cdMetadataDialogVisible) {
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
||||||
|
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
||||||
|
if (driveState === 'CD_METADATA_SELECTION' && ctx?.jobId && !cdMetadataDialogVisible) {
|
||||||
setCdMetadataDialogContext(ctx);
|
setCdMetadataDialogContext(ctx);
|
||||||
setCdMetadataDialogVisible(true);
|
setCdMetadataDialogVisible(true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
if (driveState === 'CD_READY_TO_RIP' && ctx?.jobId) {
|
||||||
if (currentState === 'CD_READY_TO_RIP') {
|
|
||||||
const ctx = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : null;
|
|
||||||
if (ctx?.jobId) {
|
|
||||||
setCdRipPanelJobId(ctx.jobId);
|
setCdRipPanelJobId(ctx.jobId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pipeline?.state, pipeline?.context?.jobId]);
|
}, [pipeline?.cdDrives]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueueState(normalizeQueue(pipeline?.queue));
|
setQueueState(normalizeQueue(pipeline?.queue));
|
||||||
@@ -1314,6 +1314,19 @@ export default function DashboardPage({
|
|||||||
await handleAnalyze();
|
await handleAnalyze();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAnalyzeForDrive = async (devicePath) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.analyzeDisc(devicePath);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRescan = async () => {
|
const handleRescan = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -1969,7 +1982,11 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const device = lastDiscEvent || pipeline?.context?.device;
|
// CD drives are tracked per-drive in pipeline.cdDrives — exclude them from the single-drive display
|
||||||
|
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
||||||
|
const contextDevice = pipeline?.context?.device;
|
||||||
|
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
||||||
|
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
|
||||||
const isDriveActive = driveActiveStates.includes(state);
|
const isDriveActive = driveActiveStates.includes(state);
|
||||||
const canRescan = !isDriveActive;
|
const canRescan = !isDriveActive;
|
||||||
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
||||||
@@ -2236,6 +2253,74 @@ export default function DashboardPage({
|
|||||||
disabled={!canOpenMetadataModal}
|
disabled={!canOpenMetadataModal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Per-drive CD sections */}
|
||||||
|
{cdDriveEntries.length > 0 && (
|
||||||
|
<div className="cd-drives-section">
|
||||||
|
{cdDriveEntries.map(([drivePath, drive]) => {
|
||||||
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
||||||
|
const driveDevice = drive?.device || {};
|
||||||
|
const driveJobId = normalizeJobId(drive?.jobId);
|
||||||
|
const driveCtx = drive?.context && typeof drive.context === 'object' ? drive.context : {};
|
||||||
|
const cdStateSeverity = driveState === 'FINISHED' ? 'success'
|
||||||
|
: driveState === 'ERROR' || driveState === 'CANCELLED' ? 'danger'
|
||||||
|
: ['CD_RIPPING', 'CD_ENCODING'].includes(driveState) ? 'warning'
|
||||||
|
: 'info';
|
||||||
|
return (
|
||||||
|
<div key={drivePath} className="cd-drive-item">
|
||||||
|
<div className="cd-drive-header">
|
||||||
|
<strong>{driveDevice.model || 'CD-Laufwerk'}</strong>
|
||||||
|
<code className="cd-drive-path">{drivePath}</code>
|
||||||
|
<Tag value={driveState} severity={cdStateSeverity} />
|
||||||
|
</div>
|
||||||
|
{driveDevice.discLabel && (
|
||||||
|
<div className="cd-drive-disc-label">
|
||||||
|
<strong>Disc:</strong> {driveDevice.discLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cd-drive-actions">
|
||||||
|
{driveState === 'DISC_DETECTED' && (
|
||||||
|
<Button
|
||||||
|
label="CD analysieren"
|
||||||
|
icon="pi pi-search"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{driveState === 'CD_METADATA_SELECTION' && (
|
||||||
|
<Button
|
||||||
|
label="Metadaten auswählen"
|
||||||
|
icon="pi pi-list"
|
||||||
|
size="small"
|
||||||
|
severity="info"
|
||||||
|
onClick={() => {
|
||||||
|
setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
}}
|
||||||
|
disabled={!driveJobId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(driveState === 'ERROR' || driveState === 'CANCELLED') && driveJobId && (
|
||||||
|
<Button
|
||||||
|
label="Erneut analysieren"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
size="small"
|
||||||
|
severity="warning"
|
||||||
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-CD / legacy single-drive display */}
|
||||||
<div className="dashboard-drive-state">
|
<div className="dashboard-drive-state">
|
||||||
{device
|
{device
|
||||||
? <Tag value="Disk eingelegt" severity="success" icon="pi pi-circle-fill" />
|
? <Tag value="Disk eingelegt" severity="success" icon="pi pi-circle-fill" />
|
||||||
@@ -2260,7 +2345,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p>Aktuell keine Disk erkannt.</p>
|
cdDriveEntries.length === 0 ? <p>Aktuell keine Disk erkannt.</p> : null
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,115 +1,34 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { DataTable } from 'primereact/datatable';
|
import { DataTable } from 'primereact/datatable';
|
||||||
import { Column } from 'primereact/column';
|
import { Column } from 'primereact/column';
|
||||||
import { InputText } from 'primereact/inputtext';
|
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
|
||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
|
||||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
|
||||||
import {
|
|
||||||
getStatusLabel,
|
|
||||||
getStatusSeverity,
|
|
||||||
normalizeStatus,
|
|
||||||
STATUS_FILTER_OPTIONS
|
|
||||||
} from '../utils/statusPresentation';
|
|
||||||
|
|
||||||
function resolveMediaType(row) {
|
function formatDetectedMediaType(value) {
|
||||||
const candidates = [
|
const mediaType = String(value || '').trim().toLowerCase();
|
||||||
row?.mediaType,
|
if (mediaType === 'bluray') {
|
||||||
row?.media_type,
|
return 'Blu-ray';
|
||||||
row?.mediaProfile,
|
|
||||||
row?.media_profile,
|
|
||||||
row?.encodePlan?.mediaProfile,
|
|
||||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
|
||||||
row?.makemkvInfo?.mediaProfile,
|
|
||||||
row?.mediainfoInfo?.mediaProfile
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const raw = String(candidate || '').trim().toLowerCase();
|
|
||||||
if (!raw) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
|
||||||
return 'bluray';
|
|
||||||
}
|
|
||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
|
||||||
return 'dvd';
|
|
||||||
}
|
|
||||||
if (['cd', 'audio_cd'].includes(raw)) {
|
|
||||||
return 'cd';
|
|
||||||
}
|
|
||||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
|
||||||
return 'audiobook';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 'other';
|
if (mediaType === 'dvd') {
|
||||||
}
|
return 'DVD';
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return Math.trunc(parsed);
|
if (mediaType === 'cd') {
|
||||||
}
|
return 'CD';
|
||||||
|
}
|
||||||
function getQueueActionResult(response) {
|
if (mediaType === 'audiobook') {
|
||||||
return response?.result && typeof response.result === 'object' ? response.result : {};
|
return 'Audiobook';
|
||||||
|
}
|
||||||
|
return 'Sonstiges';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DatabasePage() {
|
export default function DatabasePage() {
|
||||||
const [rows, setRows] = useState([]);
|
|
||||||
const [orphanRows, setOrphanRows] = useState([]);
|
const [orphanRows, setOrphanRows] = useState([]);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [orphanLoading, setOrphanLoading] = useState(false);
|
const [orphanLoading, setOrphanLoading] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState(null);
|
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
|
||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
|
||||||
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
|
||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
|
||||||
const [cdMetadataDialogBusy, setCdMetadataDialogBusy] = useState(false);
|
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
|
||||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
|
||||||
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
|
||||||
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
const queuedJobIdSet = useMemo(() => {
|
|
||||||
const next = new Set();
|
|
||||||
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
|
|
||||||
const id = normalizeJobId(value);
|
|
||||||
if (id) {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}, [queuedJobIds]);
|
|
||||||
|
|
||||||
const loadRows = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.getDatabaseRows({ search, status });
|
|
||||||
setRows(response.rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadOrphans = async () => {
|
const loadOrphans = async () => {
|
||||||
setOrphanLoading(true);
|
setOrphanLoading(true);
|
||||||
@@ -123,357 +42,9 @@ export default function DatabasePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadQueue = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.getPipelineQueue();
|
|
||||||
const queuedRows = Array.isArray(response?.queue?.queuedJobs) ? response.queue.queuedJobs : [];
|
|
||||||
const queuedIds = queuedRows
|
|
||||||
.map((item) => normalizeJobId(item?.jobId))
|
|
||||||
.filter(Boolean);
|
|
||||||
setQueuedJobIds(queuedIds);
|
|
||||||
} catch (_error) {
|
|
||||||
setQueuedJobIds([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
await Promise.all([loadRows(), loadOrphans(), loadQueue()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
void loadOrphans();
|
||||||
load();
|
}, []);
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search, status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!detailVisible || !selectedJob?.id) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldPoll =
|
|
||||||
['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(selectedJob.status) ||
|
|
||||||
(selectedJob.status === 'READY_TO_ENCODE' && !selectedJob.encodePlan);
|
|
||||||
|
|
||||||
if (!shouldPoll) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const refreshDetail = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.getJob(selectedJob.id, { includeLogs: false });
|
|
||||||
if (!cancelled) {
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore polling errors; user can manually refresh
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const interval = setInterval(refreshDetail, 2500);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [detailVisible, selectedJob?.id, selectedJob?.status, selectedJob?.encodePlan]);
|
|
||||||
|
|
||||||
const openDetail = async (row) => {
|
|
||||||
const jobId = Number(row?.id || 0);
|
|
||||||
if (!jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedJob({
|
|
||||||
...row,
|
|
||||||
logs: [],
|
|
||||||
log: '',
|
|
||||||
logMeta: {
|
|
||||||
loaded: false,
|
|
||||||
total: Number(row?.log_count || 0),
|
|
||||||
returned: 0,
|
|
||||||
truncated: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setDetailVisible(true);
|
|
||||||
setDetailLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.getJob(jobId, { includeLogs: false });
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshDetailIfOpen = async (jobId) => {
|
|
||||||
if (!detailVisible || !selectedJob || selectedJob.id !== jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.getJob(jobId, { includeLogs: false });
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadLog = async (job, mode = 'tail') => {
|
|
||||||
const jobId = Number(job?.id || selectedJob?.id || 0);
|
|
||||||
if (!jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLogLoadingMode(mode);
|
|
||||||
try {
|
|
||||||
const response = await api.getJob(jobId, {
|
|
||||||
includeLogs: true,
|
|
||||||
includeAllLogs: mode === 'all',
|
|
||||||
logTailLines: mode === 'all' ? null : 800
|
|
||||||
});
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message });
|
|
||||||
} finally {
|
|
||||||
setLogLoadingMode(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFiles = async (row, target) => {
|
|
||||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
|
||||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await api.deleteJobFiles(row.id, target);
|
|
||||||
const summary = response.summary || {};
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Dateien gelöscht',
|
|
||||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReencode = async (row) => {
|
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
|
||||||
const confirmed = window.confirm(`Re-Encode aus RAW für "${title}" starten? Der bestehende Job wird aktualisiert.`);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReencodeBusyJobId(row.id);
|
|
||||||
try {
|
|
||||||
const response = await api.reencodeJob(row.id);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Re-Encode gestartet',
|
|
||||||
detail: 'Bestehender Job wurde in die Mediainfo-Prüfung gesetzt.',
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
} finally {
|
|
||||||
setReencodeBusyJobId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestartEncode = async (row) => {
|
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
|
||||||
if (row?.encodeSuccess) {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
|
||||||
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
|
||||||
);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await api.restartEncodeWithLastSettings(row.id);
|
|
||||||
const result = getQueueActionResult(response);
|
|
||||||
if (result.queued) {
|
|
||||||
const queuePosition = Number(result?.queuePosition || 0);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'info',
|
|
||||||
summary: 'Encode-Neustart in Queue',
|
|
||||||
detail: queuePosition > 0
|
|
||||||
? `Job wurde auf Position ${queuePosition} eingeplant.`
|
|
||||||
: 'Job wurde in die Warteschlange eingeplant.',
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Encode-Neustart gestartet',
|
|
||||||
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestartReview = async (row) => {
|
|
||||||
setActionBusy(true);
|
|
||||||
try {
|
|
||||||
await api.restartReviewFromRaw(row.id);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Review-Neustart gestartet',
|
|
||||||
detail: 'Die Titel-/Spurprüfung wird aus dem RAW neu berechnet.',
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFromQueue = async (row) => {
|
|
||||||
const jobId = normalizeJobId(row?.id || row);
|
|
||||||
if (!jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActionBusy(true);
|
|
||||||
try {
|
|
||||||
await api.cancelPipeline(jobId);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Aus Queue entfernt',
|
|
||||||
detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`,
|
|
||||||
life: 3200
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(jobId);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Queue-Entfernung fehlgeschlagen',
|
|
||||||
detail: error.message,
|
|
||||||
life: 4500
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeReady = async (row) => {
|
|
||||||
setActionBusy(true);
|
|
||||||
try {
|
|
||||||
await api.resumeReadyJob(row.id);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Job ins Dashboard geladen',
|
|
||||||
detail: 'Job ist wieder im Dashboard aktiv.',
|
|
||||||
life: 3200
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
await refreshDetailIfOpen(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Laden fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDeleteChoice = (value) => {
|
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
|
||||||
if (normalized === 'raw') return 'raw';
|
|
||||||
if (normalized === 'fertig') return 'movie';
|
|
||||||
if (normalized === 'beides') return 'both';
|
|
||||||
if (normalized === 'nichts') return 'none';
|
|
||||||
if (normalized === 'movie') return 'movie';
|
|
||||||
if (normalized === 'both') return 'both';
|
|
||||||
if (normalized === 'none') return 'none';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteEntry = async (row) => {
|
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
|
||||||
const choiceRaw = window.prompt(
|
|
||||||
`Was soll beim Löschen von "${title}" mit gelöscht werden?\n` +
|
|
||||||
'- raw\n' +
|
|
||||||
'- fertig\n' +
|
|
||||||
'- beides\n' +
|
|
||||||
'- nichts',
|
|
||||||
'nichts'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (choiceRaw === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = mapDeleteChoice(choiceRaw);
|
|
||||||
if (!target) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Ungültige Eingabe',
|
|
||||||
detail: 'Bitte genau eine Option verwenden: raw, fertig, beides, nichts.',
|
|
||||||
life: 4200
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
`Historieneintrag "${title}" wirklich löschen? Auswahl: ${target === 'movie' ? 'fertig' : target}`
|
|
||||||
);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeleteEntryBusyJobId(row.id);
|
|
||||||
try {
|
|
||||||
const response = await api.deleteJobEntry(row.id, target);
|
|
||||||
const rawDeleted = response?.fileSummary?.raw?.filesDeleted ?? 0;
|
|
||||||
const movieDeleted = response?.fileSummary?.movie?.filesDeleted ?? 0;
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Historieneintrag gelöscht',
|
|
||||||
detail: `Dateien entfernt: RAW ${rawDeleted}, Fertig ${movieDeleted}`,
|
|
||||||
life: 4200
|
|
||||||
});
|
|
||||||
if (selectedJob?.id === row.id) {
|
|
||||||
setDetailVisible(false);
|
|
||||||
setSelectedJob(null);
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Löschen fehlgeschlagen',
|
|
||||||
detail: error.message,
|
|
||||||
life: 5000
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDeleteEntryBusyJobId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportOrphanRaw = async (row) => {
|
const handleImportOrphanRaw = async (row) => {
|
||||||
const target = row?.rawPath || row?.folderName || '-';
|
const target = row?.rawPath || row?.folderName || '-';
|
||||||
@@ -511,7 +82,7 @@ export default function DatabasePage() {
|
|||||||
life: 3500
|
life: 3500
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await load();
|
await loadOrphans();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -523,204 +94,15 @@ export default function DatabasePage() {
|
|||||||
setOrphanImportBusyPath(null);
|
setOrphanImportBusyPath(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOmdbSearch = async (query) => {
|
|
||||||
try {
|
|
||||||
const response = await api.searchOmdb(query);
|
|
||||||
return response.results || [];
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'OMDb Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMusicBrainzSearch = async (query) => {
|
|
||||||
try {
|
|
||||||
const response = await api.searchMusicBrainz(query);
|
|
||||||
return response.results || [];
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
|
||||||
try {
|
|
||||||
const response = await api.getMusicBrainzRelease(mbId);
|
|
||||||
return response.release || null;
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Release fehlgeschlagen', detail: error.message, life: 4500 });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCdMetadataAssignDialog = (row) => {
|
|
||||||
if (!row?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const makemkvInfo = row.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
|
||||||
const tocTracks = Array.isArray(makemkvInfo.tracks) ? makemkvInfo.tracks : [];
|
|
||||||
const selectedMetadata = makemkvInfo.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
||||||
? makemkvInfo.selectedMetadata
|
|
||||||
: {};
|
|
||||||
setCdMetadataDialogContext({
|
|
||||||
jobId: row.id,
|
|
||||||
detectedTitle: row.title || row.detected_title || selectedMetadata.title || '',
|
|
||||||
tracks: tocTracks
|
|
||||||
});
|
|
||||||
setCdMetadataDialogVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCdMetadataAssignSubmit = async (payload) => {
|
|
||||||
const jobId = Number(payload?.jobId || cdMetadataDialogContext?.jobId || 0);
|
|
||||||
if (!jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCdMetadataDialogBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await api.assignJobCdMetadata(jobId, payload);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'CD-Metadaten aktualisiert',
|
|
||||||
detail: `Job #${jobId} wurde aktualisiert.`,
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
setCdMetadataDialogVisible(false);
|
|
||||||
await load();
|
|
||||||
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
} else {
|
|
||||||
await refreshDetailIfOpen(jobId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'CD-Metadaten fehlgeschlagen',
|
|
||||||
detail: error.message,
|
|
||||||
life: 5000
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setCdMetadataDialogBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openMetadataAssignDialog = (row) => {
|
|
||||||
if (!row?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const detectedTitle = row.title || row.detected_title || '';
|
|
||||||
const imdbId = String(row.imdb_id || '').trim();
|
|
||||||
const seedRows = imdbId
|
|
||||||
? [{
|
|
||||||
title: row.title || row.detected_title || detectedTitle || imdbId,
|
|
||||||
year: row.year || '',
|
|
||||||
imdbId,
|
|
||||||
type: 'movie',
|
|
||||||
poster: row.poster_url || null
|
|
||||||
}]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
setMetadataDialogContext({
|
|
||||||
jobId: row.id,
|
|
||||||
detectedTitle,
|
|
||||||
selectedMetadata: {
|
|
||||||
title: row.title || row.detected_title || '',
|
|
||||||
year: row.year || '',
|
|
||||||
imdbId,
|
|
||||||
poster: row.poster_url || null
|
|
||||||
},
|
|
||||||
omdbCandidates: seedRows
|
|
||||||
});
|
|
||||||
setMetadataDialogVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMetadataAssignSubmit = async (payload) => {
|
|
||||||
const jobId = Number(payload?.jobId || metadataDialogContext?.jobId || 0);
|
|
||||||
if (!jobId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMetadataDialogBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await api.assignJobOmdb(jobId, payload);
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'OMDb-Zuordnung aktualisiert',
|
|
||||||
detail: `Job #${jobId} wurde aktualisiert.`,
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
setMetadataDialogVisible(false);
|
|
||||||
await load();
|
|
||||||
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
|
||||||
setSelectedJob(response.job);
|
|
||||||
} else {
|
|
||||||
await refreshDetailIfOpen(jobId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'OMDb-Zuordnung fehlgeschlagen',
|
|
||||||
detail: error.message,
|
|
||||||
life: 5000
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setMetadataDialogBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const posterBody = (row) =>
|
|
||||||
row.poster_url && row.poster_url !== 'N/A' ? (
|
|
||||||
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
|
||||||
) : (
|
|
||||||
<span>-</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const titleBody = (row) => (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.title || row.detected_title || '-'}</strong></div>
|
|
||||||
<small>{row.year || '-'} | {row.imdb_id || '-'}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const stateBody = (row) => {
|
|
||||||
const normalizedStatus = normalizeStatus(row?.status);
|
|
||||||
const rowId = normalizeJobId(row?.id);
|
|
||||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
value={getStatusLabel(row?.status, { queued: isQueued })}
|
|
||||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const mediaBody = (row) => {
|
|
||||||
const mediaType = resolveMediaType(row);
|
|
||||||
const src = mediaType === 'bluray'
|
|
||||||
? blurayIndicatorIcon
|
|
||||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
|
||||||
const alt = mediaType === 'bluray'
|
|
||||||
? 'Blu-ray'
|
|
||||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
|
||||||
const title = mediaType === 'bluray'
|
|
||||||
? 'Blu-ray'
|
|
||||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
|
||||||
const label = mediaType === 'bluray'
|
|
||||||
? 'Blu-ray'
|
|
||||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges'));
|
|
||||||
return (
|
|
||||||
<span className="job-step-cell">
|
|
||||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const orphanTitleBody = (row) => (
|
const orphanTitleBody = (row) => (
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{row.title || '-'}</strong></div>
|
<div><strong>{row.title || '-'}</strong></div>
|
||||||
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
|
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const orphanMediaBody = (row) => (
|
||||||
|
<Tag value={formatDetectedMediaType(row?.detectedMediaType)} />
|
||||||
|
);
|
||||||
const orphanPathBody = (row) => (
|
const orphanPathBody = (row) => (
|
||||||
<div className="orphan-path-cell">
|
<div className="orphan-path-cell">
|
||||||
{row.rawPath}
|
{row.rawPath}
|
||||||
@@ -733,7 +115,7 @@ export default function DatabasePage() {
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleImportOrphanRaw(row)}
|
onClick={() => handleImportOrphanRaw(row)}
|
||||||
loading={orphanImportBusyPath === row.rawPath}
|
loading={orphanImportBusyPath === row.rawPath}
|
||||||
disabled={Boolean(orphanImportBusyPath) || Boolean(actionBusy)}
|
disabled={Boolean(orphanImportBusyPath)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -741,50 +123,9 @@ export default function DatabasePage() {
|
|||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
<Card title="Historie & Datenbank" subTitle="Kompakte Übersicht, Details im Job-Modal">
|
|
||||||
<div className="table-filters">
|
|
||||||
<InputText
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="Suche nach Titel oder IMDb"
|
|
||||||
/>
|
|
||||||
<Dropdown
|
|
||||||
value={status}
|
|
||||||
options={STATUS_FILTER_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setStatus(event.value)}
|
|
||||||
placeholder="Status"
|
|
||||||
/>
|
|
||||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-scroll-wrap table-scroll-wide">
|
|
||||||
<DataTable
|
|
||||||
value={rows}
|
|
||||||
dataKey="id"
|
|
||||||
paginator
|
|
||||||
rows={10}
|
|
||||||
loading={loading}
|
|
||||||
onRowClick={(event) => openDetail(event.data)}
|
|
||||||
className="clickable-table"
|
|
||||||
emptyMessage="Keine Einträge"
|
|
||||||
responsiveLayout="scroll"
|
|
||||||
>
|
|
||||||
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
|
||||||
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
|
||||||
<Column header="Medium" body={mediaBody} style={{ width: '10rem' }} />
|
|
||||||
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
|
||||||
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
|
||||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
|
||||||
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="RAW ohne Historie"
|
title="Gefundene RAW-Einträge"
|
||||||
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,cd,audiobook,other}) ohne zugehörigen Job können hier importiert werden"
|
subTitle="Es werden nur RAW-Ordner ohne zugehörigen Historienjob aus den konfigurierten RAW-Pfaden angezeigt."
|
||||||
>
|
>
|
||||||
<div className="table-filters">
|
<div className="table-filters">
|
||||||
<Button
|
<Button
|
||||||
@@ -802,12 +143,13 @@ export default function DatabasePage() {
|
|||||||
value={orphanRows}
|
value={orphanRows}
|
||||||
dataKey="rawPath"
|
dataKey="rawPath"
|
||||||
paginator
|
paginator
|
||||||
rows={5}
|
rows={10}
|
||||||
loading={orphanLoading}
|
loading={orphanLoading}
|
||||||
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
|
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
>
|
>
|
||||||
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
|
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
|
||||||
|
<Column header="Medium" body={orphanMediaBody} style={{ width: '10rem' }} />
|
||||||
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
|
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
|
||||||
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||||
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||||
@@ -816,56 +158,6 @@ export default function DatabasePage() {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<JobDetailDialog
|
|
||||||
visible={detailVisible}
|
|
||||||
job={selectedJob}
|
|
||||||
detailLoading={detailLoading}
|
|
||||||
onLoadLog={handleLoadLog}
|
|
||||||
logLoadingMode={logLoadingMode}
|
|
||||||
onHide={() => {
|
|
||||||
setDetailVisible(false);
|
|
||||||
setDetailLoading(false);
|
|
||||||
setLogLoadingMode(null);
|
|
||||||
}}
|
|
||||||
onAssignOmdb={openMetadataAssignDialog}
|
|
||||||
onAssignCdMetadata={openCdMetadataAssignDialog}
|
|
||||||
onResumeReady={handleResumeReady}
|
|
||||||
onRestartEncode={handleRestartEncode}
|
|
||||||
onRestartReview={handleRestartReview}
|
|
||||||
onReencode={handleReencode}
|
|
||||||
onDeleteFiles={handleDeleteFiles}
|
|
||||||
onDeleteEntry={handleDeleteEntry}
|
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
|
||||||
omdbAssignBusy={metadataDialogBusy}
|
|
||||||
cdMetadataAssignBusy={cdMetadataDialogBusy}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
|
||||||
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetadataSelectionDialog
|
|
||||||
visible={metadataDialogVisible}
|
|
||||||
context={metadataDialogContext || {}}
|
|
||||||
onHide={() => setMetadataDialogVisible(false)}
|
|
||||||
onSubmit={handleMetadataAssignSubmit}
|
|
||||||
onSearch={handleOmdbSearch}
|
|
||||||
busy={metadataDialogBusy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CdMetadataDialog
|
|
||||||
visible={cdMetadataDialogVisible}
|
|
||||||
context={cdMetadataDialogContext || {}}
|
|
||||||
onHide={() => {
|
|
||||||
setCdMetadataDialogVisible(false);
|
|
||||||
setCdMetadataDialogContext(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleCdMetadataAssignSubmit}
|
|
||||||
onSearch={handleMusicBrainzSearch}
|
|
||||||
onFetchRelease={handleMusicBrainzReleaseFetch}
|
|
||||||
busy={cdMetadataDialogBusy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1723,6 +1723,72 @@ body {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drive devices list editor (Settings page) */
|
||||||
|
.drive-devices-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-devices-empty {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-device-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-device-input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-drive CD sections in Dashboard */
|
||||||
|
.cd-drives-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--rip-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-drive-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-drive-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-drive-path {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-drive-disc-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cd-drive-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-toggle-box {
|
.notification-toggle-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.2-25",
|
"version": "0.11.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user