diff --git a/backend/package-lock.json b/backend/package-lock.json index 1a1266e..b2676ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.2-15", + "version": "0.10.2-16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.2-15", + "version": "0.10.2-16", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 904dcef..e8a1aa0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.2-15", + "version": "0.10.2-16", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index d81eb05..0477029 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -961,6 +961,12 @@ async function migrateSettingsSchemaMetadata(db) { VALUES ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)` ); 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 ('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)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`); } async function getDb() { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 369a599..546477f 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -2110,9 +2110,14 @@ class HistoryService { toNormalizedPath(resolvedPaths?.rawDir), ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); + // Only configured base dirs become protectedRoots (must never be deleted themselves) const movieRoots = sanitizeRoots([ ...getConfiguredMediaPathList(settings || {}, 'movie_dir'), - toNormalizedPath(resolvedPaths?.movieDir), + toNormalizedPath(resolvedPaths?.movieDir) + ]); + // Includes parent dirs of output files for addCandidate safety checks + const movieAllowedPaths = sanitizeRoots([ + ...movieRoots, ...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); @@ -2204,7 +2209,7 @@ class HistoryService { 'movie', outputPath, artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', - movieRoots + movieAllowedPaths ); const parentDir = toNormalizedPath(path.dirname(outputPath)); if (parentDir && !movieRoots.includes(parentDir)) { @@ -2213,7 +2218,7 @@ class HistoryService { 'movie', parentDir, artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', - movieRoots + movieAllowedPaths ); } } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index ca7e4e5..b57fdc7 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -5201,6 +5201,32 @@ class PipelineService extends EventEmitter { } } + async ejectDriveIfEnabled(settingsMap, devicePath = null) { + try { + const enabled = String(settingsMap?.auto_eject_after_rip || '').trim().toLowerCase(); + if (enabled !== 'true' && enabled !== '1') { + return; + } + const device = devicePath + || (settingsMap?.drive_mode === 'explicit' ? String(settingsMap?.drive_device || '').trim() : null) + || String(settingsMap?.drive_device || '').trim() + || '/dev/sr0'; + logger.info('eject:drive', { device }); + await new Promise((resolve) => { + execFile('eject', [device], { timeout: 10000 }, (error) => { + if (error) { + logger.warn('eject:drive:failed', { device, error: errorToMeta(error) }); + } else { + logger.info('eject:drive:ok', { device }); + } + resolve(); + }); + }); + } catch (error) { + logger.warn('eject:drive:error', { error: errorToMeta(error) }); + } + } + normalizeDiscValue(value) { return String(value || '').trim().toLowerCase(); } @@ -9605,6 +9631,7 @@ class PipelineService extends EventEmitter { title: 'Ripster - Job abgeschlossen', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); + void this.ejectDriveIfEnabled(settings); } setTimeout(async () => { @@ -13267,6 +13294,7 @@ class PipelineService extends EventEmitter { title: 'Ripster - CD Rip erfolgreich', message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` }); + void this.ejectDriveIfEnabled(settings); } catch (error) { settleLifecycle(); const failedCdLive = buildLiveContext(currentTrackPosition || null); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d5d7f5..0424c08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.2-15", + "version": "0.10.2-16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.2-15", + "version": "0.10.2-16", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 4e5c80e..22ebfe4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.2-15", + "version": "0.10.2-16", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 208bdc2..7b51fe6 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -143,7 +143,14 @@ function normalizeRuntimeActivitiesPayload(rawPayload) { jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null, cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null, canCancel: Boolean(source.canCancel), - canNextStep: Boolean(source.canNextStep) + canNextStep: Boolean(source.canNextStep), + parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0 + ? Math.trunc(Number(source.parentActivityId)) + : null, + stepIndex: Number.isFinite(Number(source.stepIndex)) ? Math.trunc(Number(source.stepIndex)) : null, + stepTotal: Number.isFinite(Number(source.stepTotal)) && Number(source.stepTotal) > 0 + ? Math.trunc(Number(source.stepTotal)) + : null }; }; const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem); @@ -2109,6 +2116,18 @@ export default function DashboardPage({ ? runtimeActivities.recent.slice(0, 8) : []; + // Group chain activities with their current child script — hide child scripts from top level + const activeChainChildIds = new Set( + runtimeActiveItems.filter((i) => i.parentActivityId != null).map((i) => i.id) + ); + const activeItemsDisplay = runtimeActiveItems.filter((i) => !activeChainChildIds.has(i.id)); + const findActiveChainChild = (chainId) => runtimeActiveItems.find((i) => i.parentActivityId === chainId) || null; + + const recentChainChildIds = new Set( + runtimeRecentItems.filter((i) => i.parentActivityId != null).map((i) => i.id) + ); + const recentItemsDisplay = runtimeRecentItems.filter((i) => !recentChainChildIds.has(i.id)); + return (