From df708485b5ef9f7531ff9223529bde00f89fd14a Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Fri, 13 Mar 2026 15:50:45 +0000 Subject: [PATCH] merge --- backend/src/db/database.js | 22 ++++++- backend/src/services/historyService.js | 63 ++++++++++++++---- backend/src/services/pipelineService.js | 30 +++++---- backend/src/services/settingsService.js | 10 +-- db/schema.sql | 12 +++- .../src/components/DynamicSettingsForm.jsx | 11 +++- frontend/src/pages/DashboardPage.jsx | 66 +++++++++++-------- 7 files changed, 153 insertions(+), 61 deletions(-) diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 91dbf9f..7733456 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -857,7 +857,7 @@ async function migrateSettingsSchemaMetadata(db) { } const rawDirCdLabel = 'CD RAW-Ordner'; - const rawDirCdDescription = 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).'; + const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).'; const rawDirCdResult = await db.run( `UPDATE settings_schema SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP @@ -870,6 +870,26 @@ async function migrateSettingsSchemaMetadata(db) { label: rawDirCdLabel }); } + + // Migrate raw_dir_cd_owner label + await db.run( + `UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP + WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'` + ); + + // Add movie_dir_cd if not already present + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`); + + // Add movie_dir_cd_owner if not already present + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`); } async function getDb() { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 15b1e9d..a255f8a 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -161,6 +161,41 @@ function hasBlurayStructure(rawPath) { return false; } +function hasCdStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + try { + if (!fs.existsSync(basePath)) { + return false; + } + const stat = fs.statSync(basePath); + if (!stat.isDirectory()) { + return false; + } + const entries = fs.readdirSync(basePath); + const audioExtensions = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg', '.aiff', '.aif']); + return entries.some((entry) => audioExtensions.has(path.extname(entry).toLowerCase())); + } catch (_error) { + return false; + } +} + +function detectOrphanMediaType(rawPath) { + if (hasBlurayStructure(rawPath)) { + return 'bluray'; + } + if (hasDvdStructure(rawPath)) { + return 'dvd'; + } + if (hasCdStructure(rawPath)) { + return 'cd'; + } + return 'other'; +} + function hasDvdStructure(rawPath) { const basePath = String(rawPath || '').trim(); if (!basePath) { @@ -439,17 +474,16 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); const rawDir = String(effectiveSettings?.raw_dir || '').trim(); const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); - const movieDir = mediaType === 'cd' ? rawDir : configuredMovieDir; + const movieDir = configuredMovieDir || rawDir; const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir') .filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir)); const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); - const effectiveOutputPath = mediaType === 'cd' - ? (job?.output_path || null) - : (configuredMovieDir && job?.output_path - ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) - : (job?.output_path || null)); + // For CD, output_path is a directory (album folder) — skip path-relocation heuristic + const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path) + ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) + : (job?.output_path || null); return { mediaType, @@ -1398,6 +1432,7 @@ class HistoryService { const stat = fs.statSync(rawPath); const metadata = parseRawFolderMetadata(entry.name); + const detectedMediaType = detectOrphanMediaType(rawPath); orphanRows.push({ rawPath, folderName: entry.name, @@ -1406,7 +1441,10 @@ class HistoryService { imdbId: metadata.imdbId, folderJobId: metadata.folderJobId, entryCount: Number(dirInfo.entryCount || 0), - hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), + detectedMediaType, + hasBlurayStructure: detectedMediaType === 'bluray', + hasDvdStructure: detectedMediaType === 'dvd', + hasCdStructure: detectedMediaType === 'cd', lastModifiedAt: stat.mtime.toISOString() }); seenOrphanPaths.add(normalizedPath); @@ -1543,6 +1581,7 @@ class HistoryService { } } + const detectedMediaType = detectOrphanMediaType(finalRawPath); const orphanPosterUrl = omdbById?.poster || null; await this.updateJob(created.id, { status: 'FINISHED', @@ -1567,7 +1606,8 @@ class HistoryService { status: 'SUCCESS', source: 'orphan_raw_import', importedAt, - rawPath: finalRawPath + rawPath: finalRawPath, + mediaProfile: detectedMediaType }) }); @@ -1585,8 +1625,8 @@ class HistoryService { created.id, 'SYSTEM', renameSteps.length > 0 - ? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` - : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}` + ? `Historieneintrag aus RAW erstellt (Medientyp: ${detectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` + : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${detectedMediaType})` ); if (metadata.imdbId) { await this.appendLog( @@ -1600,7 +1640,8 @@ class HistoryService { logger.info('job:import-orphan-raw', { jobId: created.id, - rawPath: absRawPath + rawPath: absRawPath, + detectedMediaType }); const imported = await this.getJobById(created.id); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index fb239ee..e8f7099 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -10377,10 +10377,10 @@ class PipelineService extends EventEmitter { logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) }); throw error; } finally { - await historyService.closeProcessLog(jobId); this.activeProcesses.delete(Number(normalizedJobId)); - this.syncPrimaryActiveProcess(); this.cancelRequestedByJob.delete(Number(normalizedJobId)); + this.syncPrimaryActiveProcess(); + await historyService.closeProcessLog(jobId); await this.emitQueueChanged(); void this.pumpQueue(); } @@ -10430,16 +10430,16 @@ class PipelineService extends EventEmitter { hasRawPath = false; } - if (normalizedStage === 'ENCODING' && hasConfirmedPlan) { + if (normalizedStage === 'ENCODING' && hasConfirmedPlan && !isCancelled) { try { await historyService.appendLog( jobId, 'SYSTEM', - `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` + `Fehler in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` ); await this.restartEncodeWithLastSettings(jobId, { immediate: true, - triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode' + triggerReason: 'failed_encode' }); this.cancelRequestedByJob.delete(Number(jobId)); return; @@ -10992,20 +10992,22 @@ class PipelineService extends EventEmitter { const cdOutputTemplate = String( settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; - const cdBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; - const cdOutputOwner = String(settings.raw_dir_owner || '').trim(); + const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; + const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir; + const cdRawOwner = String(settings.raw_dir_owner || '').trim(); + const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim(); const cdMetadataBase = buildRawMetadataBase({ title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, year: effectiveSelectedMeta?.year || null }, activeJobId); const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); - const rawJobDir = path.join(cdBaseDir, rawDirName); + const rawJobDir = path.join(cdRawBaseDir, rawDirName); const rawWavDir = rawJobDir; - const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate); - ensureDir(cdBaseDir); + const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); + ensureDir(cdRawBaseDir); ensureDir(rawJobDir); ensureDir(outputDir); - chownRecursive(rawJobDir, cdOutputOwner); + chownRecursive(rawJobDir, cdRawOwner); chownRecursive(outputDir, cdOutputOwner); const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); @@ -11108,12 +11110,13 @@ class PipelineService extends EventEmitter { devicePath, cdparanoiaCmd, rawWavDir, - rawBaseDir: cdBaseDir, + rawBaseDir: cdRawBaseDir, cdMetadataBase, outputDir, format, formatOptions, outputTemplate: cdOutputTemplate, + rawOwner: cdRawOwner, outputOwner: cdOutputOwner, selectedTrackPositions: effectiveSelectedTrackPositions, tocTracks: mergedTracks, @@ -11142,6 +11145,7 @@ class PipelineService extends EventEmitter { format, formatOptions, outputTemplate, + rawOwner, outputOwner, selectedTrackPositions, tocTracks, @@ -11397,7 +11401,7 @@ class PipelineService extends EventEmitter { await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); } - chownRecursive(activeRawDir, outputOwner); + chownRecursive(activeRawDir, rawOwner || outputOwner); chownRecursive(outputDir, outputOwner); await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); const finishedStatusText = postEncodeScriptsSummary.failed > 0 diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index f750bbc..dab897b 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -52,11 +52,13 @@ const PROFILED_SETTINGS = { }, movie_dir: { bluray: 'movie_dir_bluray', - dvd: 'movie_dir_dvd' + dvd: 'movie_dir_dvd', + cd: 'movie_dir_cd' }, movie_dir_owner: { bluray: 'movie_dir_bluray_owner', - dvd: 'movie_dir_dvd_owner' + dvd: 'movie_dir_dvd_owner', + cd: 'movie_dir_cd_owner' }, mediainfo_extra_args: { bluray: 'mediainfo_extra_args_bluray', @@ -690,7 +692,7 @@ class SettingsService { if (legacyKey === 'raw_dir') { resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR; } else if (legacyKey === 'movie_dir') { - resolvedValue = DEFAULT_MOVIE_DIR; + resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR; } } effective[legacyKey] = resolvedValue; @@ -725,7 +727,7 @@ class SettingsService { return { bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir }, dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir }, - cd: { raw: cd.raw_dir }, + cd: { raw: cd.raw_dir, movies: cd.movie_dir }, defaults: { raw: DEFAULT_RAW_DIR, movies: DEFAULT_MOVIE_DIR, diff --git a/db/schema.sql b/db/schema.sql index 8c82408..284eda6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -373,13 +373,21 @@ VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - -- Pfade – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); +VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); +VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL); + -- Metadaten INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 0ad4db9..2caa0c0 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -160,7 +160,7 @@ function buildToolSections(settings) { // Path keys per medium — _owner keys are rendered inline const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray']; const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd']; -const CD_PATH_KEYS = ['raw_dir_cd', 'cd_output_template']; +const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template']; const LOG_PATH_KEYS = ['log_dir']; function buildSectionsForCategory(categoryName, settings) { @@ -381,6 +381,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect const dvdRaw = ep.dvd?.raw || defaultRaw; const dvdMovies = ep.dvd?.movies || defaultMovies; const cdRaw = ep.cd?.raw || defaultCd; + const cdMovies = ep.cd?.movies || cdRaw; const isDefault = (path, def) => path === def; @@ -424,11 +425,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect - CD / Audio (RAW + Output) - + CD / Audio + {cdRaw} {isDefault(cdRaw, defaultCd) && Standard} + + {cdMovies} + {isDefault(cdMovies, cdRaw) && Standard} + diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 4d36b7c..1d7c116 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -768,6 +768,20 @@ export default function DashboardPage({ const memoryMetrics = monitoringSample?.memory || null; const gpuMetrics = monitoringSample?.gpu || null; const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : []; + const storageGroups = useMemo(() => { + const groups = []; + const mountMap = new Map(); + for (const entry of storageMetrics) { + const groupKey = entry?.mountPoint || `__no_mount_${entry?.key}`; + if (!mountMap.has(groupKey)) { + const group = { mountPoint: entry?.mountPoint || null, entries: [], representative: entry }; + mountMap.set(groupKey, group); + groups.push(group); + } + mountMap.get(groupKey).entries.push(entry); + } + return groups; + }, [storageMetrics]); const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : []; const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; @@ -1854,57 +1868,55 @@ export default function DashboardPage({

Freier Speicher in Pfaden

- {storageMetrics.map((entry) => { - const tone = getStorageUsageTone(entry?.usagePercent); - const usagePercent = Number(entry?.usagePercent); + {storageGroups.map((group) => { + const rep = group.representative; + const tone = getStorageUsageTone(rep?.usagePercent); + const usagePercent = Number(rep?.usagePercent); const barValue = Number.isFinite(usagePercent) ? Math.max(0, Math.min(100, usagePercent)) : 0; - const hasError = Boolean(entry?.error); - const entryKey = entry?.key || entry?.path || 'storage-entry'; + const hasError = group.entries.every((e) => e?.error); + const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-'); return (
- {entry?.label || entry?.key || 'Pfad'} + {group.entries.map((e) => e?.label || e?.key || 'Pfad').join(' · ')} - {hasError ? 'Fehler' : formatPercent(entry?.usagePercent)} + {hasError ? 'Fehler' : formatPercent(rep?.usagePercent)}
{hasError ? ( - {entry?.error} + {rep?.error} ) : ( <>
- Frei: {formatBytes(entry?.freeBytes)} - Gesamt: {formatBytes(entry?.totalBytes)} + Frei: {formatBytes(rep?.freeBytes)} + Gesamt: {formatBytes(rep?.totalBytes)}
)} -
- Pfad: - - {entry?.path || '-'} - - {entry?.mountPoint ? ( - - Mount: {entry.mountPoint} + {group.entries.map((entry) => ( +
+ {entry?.label || entry?.key}: + + {entry?.path || '-'} - ) : null} - {entry?.queryPath && entry.queryPath !== entry.path ? ( - - Parent: {entry.queryPath} - - ) : null} - {entry?.note ? {entry.note} : null} -
+ {entry?.queryPath && entry.queryPath !== entry.path ? ( + + (Parent: {entry.queryPath}) + + ) : null} + {entry?.note ? {entry.note} : null} +
+ ))}
); })}