From e1a87af16a8f36ba6c59e1dc98dcc7d5221859cd Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Fri, 6 Mar 2026 11:21:25 +0000 Subject: [PATCH] DVD Integration --- .claude/settings.json | 4 +- backend/src/db/database.js | 119 ++- backend/src/db/defaultSettings.js | 306 +++++--- backend/src/services/diskDetectionService.js | 131 +++- backend/src/services/historyService.js | 249 +++++-- backend/src/services/pipelineService.js | 691 ++++++++++++++++-- backend/src/services/settingsService.js | 192 ++++- backend/src/utils/progressParsers.js | 15 +- docs/getting-started/prerequisites.md | 12 +- docs/tools/makemkv.md | 42 ++ frontend/src/assets/media-other.svg | 10 + .../src/components/DynamicSettingsForm.jsx | 91 +-- frontend/src/components/JobDetailDialog.jsx | 70 +- .../src/components/MediaInfoReviewPanel.jsx | 364 +++++---- .../src/components/PipelineStatusCard.jsx | 306 +++++--- frontend/src/pages/DashboardPage.jsx | 22 +- frontend/src/pages/DatabasePage.jsx | 25 +- frontend/src/pages/HistoryPage.jsx | 597 ++++++++++++--- frontend/src/pages/SettingsPage.jsx | 3 +- frontend/src/styles/app.css | 348 +++++++++ 20 files changed, 2900 insertions(+), 697 deletions(-) create mode 100644 frontend/src/assets/media-other.svg diff --git a/.claude/settings.json b/.claude/settings.json index 9d990e7..16a7161 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,9 @@ "Bash(ls /home/michael/ripster/backend/*.sh)", "Bash(systemctl list-units --type=service)", "Bash(pip install -q -r requirements-docs.txt)", - "Bash(mkdocs build --strict)" + "Bash(mkdocs build --strict)", + "Read(//mnt/external/media/**)", + "WebFetch(domain:www.makemkv.com)" ] } } diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 5777d92..07f6d35 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -9,6 +9,44 @@ const { errorToMeta } = require('../utils/errorMeta'); const { setLogRootDir, getJobLogDir } = require('../services/logPathService'); const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql'); +const LEGACY_PROFILE_SETTING_MIGRATIONS = [ + { + legacyKey: 'mediainfo_extra_args', + profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd'] + }, + { + legacyKey: 'makemkv_rip_mode', + profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd'] + }, + { + legacyKey: 'makemkv_analyze_extra_args', + profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd'] + }, + { + legacyKey: 'makemkv_rip_extra_args', + profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd'] + }, + { + legacyKey: 'handbrake_preset', + profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd'] + }, + { + legacyKey: 'handbrake_extra_args', + profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd'] + }, + { + legacyKey: 'output_extension', + profileKeys: ['output_extension_bluray', 'output_extension_dvd'] + }, + { + legacyKey: 'filename_template', + profileKeys: ['filename_template_bluray', 'filename_template_dvd'] + }, + { + legacyKey: 'output_folder_template', + profileKeys: ['output_folder_template_bluray', 'output_folder_template_dvd'] + } +]; let dbInstance; @@ -484,6 +522,7 @@ async function openAndPrepareDatabase() { await applySchemaModel(dbInstance, schemaModel); await seedDefaultSettings(dbInstance); + await migrateLegacyProfiledToolSettings(dbInstance); await removeDeprecatedSettings(dbInstance); await ensurePipelineStateRow(dbInstance); const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true }); @@ -573,6 +612,72 @@ async function seedDefaultSettings(db) { logger.info('seed:settings', { count: seeded }); } +async function readCurrentOrDefaultSettingValue(db, key) { + if (!key) { + return null; + } + return db.get( + ` + SELECT + s.default_value AS defaultValue, + v.value AS currentValue, + COALESCE(v.value, s.default_value) AS effectiveValue + FROM settings_schema s + LEFT JOIN settings_values v ON v.key = s.key + WHERE s.key = ? + LIMIT 1 + `, + [key] + ); +} + +async function migrateLegacyProfiledToolSettings(db) { + let copiedCount = 0; + for (const migration of LEGACY_PROFILE_SETTING_MIGRATIONS) { + const legacyRow = await readCurrentOrDefaultSettingValue(db, migration.legacyKey); + if (!legacyRow) { + continue; + } + + for (const targetKey of migration.profileKeys || []) { + const targetRow = await readCurrentOrDefaultSettingValue(db, targetKey); + if (!targetRow) { + continue; + } + + const currentValue = targetRow.currentValue; + const defaultValue = targetRow.defaultValue; + const shouldCopy = ( + currentValue === null + || currentValue === undefined + || String(currentValue) === String(defaultValue ?? '') + ); + if (!shouldCopy) { + continue; + } + + await db.run( + ` + INSERT INTO settings_values (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + `, + [targetKey, legacyRow.effectiveValue ?? null] + ); + copiedCount += 1; + logger.info('migrate:legacy-tool-setting-copied', { + from: migration.legacyKey, + to: targetKey + }); + } + } + if (copiedCount > 0) { + logger.info('migrate:legacy-tool-settings:done', { copiedCount }); + } +} + async function ensurePipelineStateRow(db) { await db.run( ` @@ -584,7 +689,19 @@ async function ensurePipelineStateRow(db) { } async function removeDeprecatedSettings(db) { - const deprecatedKeys = ['pushover_notify_disc_detected']; + const deprecatedKeys = [ + 'pushover_notify_disc_detected', + 'mediainfo_extra_args', + 'makemkv_rip_mode', + 'makemkv_analyze_extra_args', + 'makemkv_rip_extra_args', + 'handbrake_preset', + 'handbrake_extra_args', + 'output_extension', + 'filename_template', + 'output_folder_template', + 'makemkv_backup_mode' + ]; for (const key of deprecatedKeys) { const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]); if (result?.changes > 0) { diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js index 90f6c6b..1257cef 100644 --- a/backend/src/db/defaultSettings.js +++ b/backend/src/db/defaultSettings.js @@ -146,18 +146,6 @@ const defaultSchema = [ validation: { minLength: 1 }, orderIndex: 205 }, - { - key: 'mediainfo_extra_args', - category: 'Tools', - label: 'Mediainfo Extra Args', - type: 'string', - required: 0, - description: 'Zusätzliche CLI-Parameter für mediainfo.', - defaultValue: '', - options: [], - validation: {}, - orderIndex: 206 - }, { key: 'makemkv_min_length_minutes', category: 'Tools', @@ -170,57 +158,6 @@ const defaultSchema = [ validation: { min: 1, max: 1000 }, orderIndex: 210 }, - { - key: 'pipeline_max_parallel_jobs', - category: 'Tools', - label: 'Parallele Jobs', - type: 'number', - required: 1, - description: 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.', - defaultValue: '1', - options: [], - validation: { min: 1, max: 12 }, - orderIndex: 211 - }, - { - key: 'makemkv_rip_mode', - category: 'Tools', - label: 'MakeMKV Rip Modus', - type: 'select', - required: 1, - description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', - defaultValue: 'backup', - options: [ - { label: 'MKV', value: 'mkv' }, - { label: 'Backup', value: 'backup' } - ], - validation: {}, - orderIndex: 212 - }, - { - key: 'makemkv_analyze_extra_args', - category: 'Tools', - label: 'MakeMKV Analyze Extra Args', - type: 'string', - required: 0, - description: 'Zusätzliche CLI-Parameter für Analyze.', - defaultValue: '', - options: [], - validation: {}, - orderIndex: 220 - }, - { - key: 'makemkv_rip_extra_args', - category: 'Tools', - label: 'MakeMKV Rip Extra Args', - type: 'string', - required: 0, - description: 'Zusätzliche CLI-Parameter für Rip.', - defaultValue: '', - options: [], - validation: {}, - orderIndex: 230 - }, { key: 'handbrake_command', category: 'Tools', @@ -231,31 +168,7 @@ const defaultSchema = [ defaultValue: 'HandBrakeCLI', options: [], validation: { minLength: 1 }, - orderIndex: 300 - }, - { - key: 'handbrake_preset', - category: 'Tools', - label: 'HandBrake Preset', - type: 'string', - required: 1, - description: 'Preset Name für -Z.', - defaultValue: 'H.264 MKV 1080p30', - options: [], - validation: { minLength: 1 }, - orderIndex: 310 - }, - { - key: 'handbrake_extra_args', - category: 'Tools', - label: 'HandBrake Extra Args', - type: 'string', - required: 0, - description: 'Zusätzliche CLI-Argumente.', - defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1', - options: [], - validation: {}, - orderIndex: 320 + orderIndex: 215 }, { key: 'handbrake_restart_delete_incomplete_output', @@ -267,15 +180,102 @@ const defaultSchema = [ defaultValue: 'true', options: [], validation: {}, + orderIndex: 220 + }, + { + key: 'pipeline_max_parallel_jobs', + category: 'Tools', + label: 'Parallele Jobs', + type: 'number', + required: 1, + description: 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.', + defaultValue: '1', + options: [], + validation: { min: 1, max: 12 }, + orderIndex: 225 + }, + { + key: 'mediainfo_extra_args_bluray', + category: 'Tools', + label: 'Mediainfo Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 300 + }, + { + key: 'makemkv_rip_mode_bluray', + category: 'Tools', + label: 'MakeMKV Rip Modus', + type: 'select', + required: 1, + description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', + defaultValue: 'backup', + options: [ + { label: 'MKV', value: 'mkv' }, + { label: 'Backup', value: 'backup' } + ], + validation: {}, + orderIndex: 305 + }, + { + key: 'makemkv_analyze_extra_args_bluray', + category: 'Tools', + label: 'MakeMKV Analyze Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 310 + }, + { + key: 'makemkv_rip_extra_args_bluray', + category: 'Tools', + label: 'MakeMKV Rip Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Rip (Blu-ray).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 315 + }, + { + key: 'handbrake_preset_bluray', + category: 'Tools', + label: 'HandBrake Preset', + type: 'string', + required: 1, + description: 'Preset Name für -Z (Blu-ray).', + defaultValue: 'H.264 MKV 1080p30', + options: [], + validation: { minLength: 1 }, + orderIndex: 320 + }, + { + key: 'handbrake_extra_args_bluray', + category: 'Tools', + label: 'HandBrake Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Argumente (Blu-ray).', + defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1', + options: [], + validation: {}, orderIndex: 325 }, { - key: 'output_extension', + key: 'output_extension_bluray', category: 'Tools', label: 'Ausgabeformat', type: 'select', required: 1, - description: 'Dateiendung für finale Datei.', + description: 'Dateiendung für finale Datei (Blu-ray).', defaultValue: 'mkv', options: [ { label: 'MKV', value: 'mkv' }, @@ -285,28 +285,142 @@ const defaultSchema = [ orderIndex: 330 }, { - key: 'filename_template', + key: 'filename_template_bluray', category: 'Tools', label: 'Dateiname Template', type: 'string', required: 1, - description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId}.', + description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (Blu-ray).', defaultValue: '${title} (${year})', options: [], validation: { minLength: 1 }, - orderIndex: 340 + orderIndex: 335 }, { - key: 'output_folder_template', + key: 'output_folder_template_bluray', category: 'Tools', label: 'Ordnername Template', type: 'string', required: 0, - description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template verwenden.', + description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (Blu-ray).', defaultValue: '', options: [], validation: {}, - orderIndex: 345 + orderIndex: 340 + }, + { + key: 'mediainfo_extra_args_dvd', + category: 'Tools', + label: 'Mediainfo Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für mediainfo (DVD).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 500 + }, + { + key: 'makemkv_rip_mode_dvd', + category: 'Tools', + label: 'MakeMKV Rip Modus', + type: 'select', + required: 1, + description: 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', + defaultValue: 'mkv', + options: [ + { label: 'MKV', value: 'mkv' }, + { label: 'Backup', value: 'backup' } + ], + validation: {}, + orderIndex: 505 + }, + { + key: 'makemkv_analyze_extra_args_dvd', + category: 'Tools', + label: 'MakeMKV Analyze Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Analyze (DVD).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 510 + }, + { + key: 'makemkv_rip_extra_args_dvd', + category: 'Tools', + label: 'MakeMKV Rip Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Rip (DVD).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 515 + }, + { + key: 'handbrake_preset_dvd', + category: 'Tools', + label: 'HandBrake Preset', + type: 'string', + required: 1, + description: 'Preset Name für -Z (DVD).', + defaultValue: 'H.264 MKV 480p30', + options: [], + validation: { minLength: 1 }, + orderIndex: 520 + }, + { + key: 'handbrake_extra_args_dvd', + category: 'Tools', + label: 'HandBrake Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Argumente (DVD).', + defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1', + options: [], + validation: {}, + orderIndex: 525 + }, + { + key: 'output_extension_dvd', + category: 'Tools', + label: 'Ausgabeformat', + type: 'select', + required: 1, + description: 'Dateiendung für finale Datei (DVD).', + defaultValue: 'mkv', + options: [ + { label: 'MKV', value: 'mkv' }, + { label: 'MP4', value: 'mp4' } + ], + validation: {}, + orderIndex: 530 + }, + { + key: 'filename_template_dvd', + category: 'Tools', + label: 'Dateiname Template', + type: 'string', + required: 1, + description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (DVD).', + defaultValue: '${title} (${year})', + options: [], + validation: { minLength: 1 }, + orderIndex: 535 + }, + { + key: 'output_folder_template_dvd', + category: 'Tools', + label: 'Ordnername Template', + type: 'string', + required: 0, + description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 540 }, { key: 'omdb_api_key', diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index adf35a3..eb24e49 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -20,7 +20,42 @@ function flattenDevices(nodes, acc = []) { } function buildSignature(info) { - return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`; + return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`; +} + +function normalizeMediaProfile(rawValue) { + const value = String(rawValue || '').trim().toLowerCase(); + if (!value) { + return null; + } + if (value === 'bluray' || value === 'blu-ray' || value === 'bd' || value === 'bdmv') { + return 'bluray'; + } + if (value === 'dvd') { + return 'dvd'; + } + if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') { + return 'other'; + } + return null; +} + +function inferMediaProfileFromTextParts(parts) { + const markerText = (parts || []) + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + + if (!markerText) { + return null; + } + if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) { + return 'bluray'; + } + if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) { + return 'dvd'; + } + return null; } class DiskDetectionService extends EventEmitter { @@ -265,6 +300,14 @@ class DiskDetectionService extends EventEmitter { const details = await this.getBlockDeviceInfo(); const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + const mediaProfile = await this.inferMediaProfile(devicePath, { + discLabel, + label: match.label, + model: match.model, + fstype: match.fstype, + mountpoint: match.mountpoint + }); + const detected = { mode: 'explicit', path: devicePath, @@ -274,6 +317,7 @@ class DiskDetectionService extends EventEmitter { discLabel: discLabel || null, mountpoint: match.mountpoint || null, fstype: match.fstype || null, + mediaProfile: mediaProfile || null, index: this.guessDiscIndex(match.name || devicePath) }; logger.debug('detect:explicit:success', { detected }); @@ -304,6 +348,14 @@ class DiskDetectionService extends EventEmitter { } const discLabel = await this.getDiscLabel(path); + const mediaProfile = await this.inferMediaProfile(path, { + discLabel, + label: item.label, + model: item.model, + fstype: item.fstype, + mountpoint: item.mountpoint + }); + const detected = { mode: 'auto', path, @@ -313,6 +365,7 @@ class DiskDetectionService extends EventEmitter { discLabel: discLabel || null, mountpoint: item.mountpoint || null, fstype: item.fstype || null, + mediaProfile: mediaProfile || null, index: this.guessDiscIndex(item.name) }; logger.debug('detect:auto:success', { detected }); @@ -372,6 +425,82 @@ class DiskDetectionService extends EventEmitter { } } + async inferMediaProfile(devicePath, hints = {}) { + const explicit = normalizeMediaProfile(hints?.mediaProfile); + if (explicit) { + return explicit; + } + + const hinted = inferMediaProfileFromTextParts([ + hints?.discLabel, + hints?.label, + hints?.fstype + ]); + if (hinted) { + return hinted; + } + + const mountpoint = String(hints?.mountpoint || '').trim(); + if (mountpoint) { + try { + if (fs.existsSync(`${mountpoint}/BDMV`)) { + return 'bluray'; + } + } catch (_error) { + // ignore fs errors + } + try { + if (fs.existsSync(`${mountpoint}/VIDEO_TS`)) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + } + + try { + const { stdout } = await execFileAsync('blkid', ['-o', 'export', devicePath]); + const payload = {}; + for (const line of String(stdout || '').split(/\r?\n/)) { + const idx = line.indexOf('='); + if (idx <= 0) { + continue; + } + const key = String(line.slice(0, idx)).trim().toUpperCase(); + const value = String(line.slice(idx + 1)).trim(); + if (!key) { + continue; + } + payload[key] = value; + } + + const byBlkidMarker = inferMediaProfileFromTextParts([ + payload.LABEL, + payload.TYPE, + payload.VERSION + ]); + if (byBlkidMarker) { + return byBlkidMarker; + } + + const type = String(payload.TYPE || '').trim().toLowerCase(); + if (type === 'udf') { + const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); + if (Number.isFinite(version)) { + return version >= 2 ? 'bluray' : 'dvd'; + } + return 'dvd'; + } + } catch (error) { + logger.debug('infer-media-profile:blkid-failed', { + devicePath, + error: errorToMeta(error) + }); + } + + return null; + } + guessDiscIndex(name) { if (!name) { return 0; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index a793e91..3bc0b0f 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -135,27 +135,103 @@ function hasBlurayStructure(rawPath) { return false; } +function hasDvdStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + const videoTsPath = path.join(basePath, 'VIDEO_TS'); + try { + if (fs.existsSync(videoTsPath)) { + const stat = fs.statSync(videoTsPath); + if (stat.isDirectory()) { + return true; + } + } + } catch (_error) { + // ignore fs errors + } + + try { + if (fs.existsSync(basePath)) { + const stat = fs.statSync(basePath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(basePath); + if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) { + return true; + } + } else if (stat.isFile()) { + return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath); + } + } + } catch (_error) { + // ignore fs errors and fallback to path checks + } + + if (/(^|\/)video_ts(\/|$)/i.test(basePath)) { + return true; + } + + return false; +} + +function normalizeMediaTypeValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + return 'bluray'; + } + if (raw === 'dvd') { + return 'dvd'; + } + if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { + return 'other'; + } + return null; +} + function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { const mkInfo = parseInfoFromValue(makemkvInfo, null); const miInfo = parseInfoFromValue(mediainfoInfo, null); const plan = parseInfoFromValue(encodePlan, null); const rawPath = String(job?.raw_path || '').trim(); const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim(); + const profileHint = normalizeMediaTypeValue( + plan?.mediaProfile + || mkInfo?.analyzeContext?.mediaProfile + || mkInfo?.mediaProfile + || miInfo?.mediaProfile + || job?.media_type + || job?.mediaType + ); + + if (profileHint === 'bluray' || profileHint === 'dvd') { + return profileHint; + } if (hasBlurayStructure(rawPath)) { return 'bluray'; } + if (hasDvdStructure(rawPath)) { + return 'dvd'; + } const mkSource = String(mkInfo?.source || '').trim().toLowerCase(); const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase(); - if ( - mkRipMode === 'backup' - || mkSource.includes('backup') - || mkSource.includes('raw_backup') - || Boolean(mkInfo?.analyzeContext?.playlistAnalysis) - ) { + if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) { return 'bluray'; } + if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) { + if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) { + return 'dvd'; + } + if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) { + return 'bluray'; + } + } const planMode = String(plan?.mode || '').trim().toLowerCase(); if (planMode === 'pre_rip' || Boolean(plan?.preRip)) { @@ -163,9 +239,17 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { } const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase(); - if (mediainfoSource.includes('raw_backup') || Number(miInfo?.handbrakeTitleId) > 0) { + if (Number(miInfo?.handbrakeTitleId) > 0) { return 'bluray'; } + if (mediainfoSource.includes('raw_backup')) { + if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) { + return 'dvd'; + } + if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) { + return 'bluray'; + } + } if ( /(^|\/)bdmv(\/|$)/i.test(rawPath) @@ -174,8 +258,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { ) { return 'bluray'; } + if ( + /(^|\/)video_ts(\/|$)/i.test(rawPath) + || /(^|\/)video_ts(\/|$)/i.test(encodeInputPath) + || /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath) + ) { + return 'dvd'; + } - return 'disc'; + return profileHint || 'other'; } function toProcessLogPath(jobId) { @@ -199,11 +290,39 @@ function toProcessLogStreamKey(jobId) { return String(Math.trunc(normalizedId)); } -function enrichJobRow(job) { - const rawStatus = inspectDirectory(job.raw_path); - const outputStatus = inspectOutputFile(job.output_path); - const movieDir = job.output_path ? path.dirname(job.output_path) : null; - const movieDirStatus = inspectDirectory(movieDir); +function resolveEffectiveRawPath(storedPath, rawDir) { + const stored = String(storedPath || '').trim(); + if (!stored || !rawDir) return stored; + const folderName = path.basename(stored); + if (!folderName) return stored; + return path.join(String(rawDir).trim(), folderName); +} + +function resolveEffectiveOutputPath(storedPath, movieDir) { + const stored = String(storedPath || '').trim(); + if (!stored || !movieDir) return stored; + // output_path structure: {movie_dir}/{folderName}/{fileName} + const fileName = path.basename(stored); + const folderName = path.basename(path.dirname(stored)); + if (!fileName || !folderName || folderName === '.') return stored; + return path.join(String(movieDir).trim(), folderName, fileName); +} + +function enrichJobRow(job, settings = null) { + const rawDir = String(settings?.raw_dir || '').trim(); + const movieDir = String(settings?.movie_dir || '').trim(); + + const effectiveRawPath = rawDir && job.raw_path + ? resolveEffectiveRawPath(job.raw_path, rawDir) + : (job.raw_path || null); + const effectiveOutputPath = movieDir && job.output_path + ? resolveEffectiveOutputPath(job.output_path, movieDir) + : (job.output_path || null); + + const rawStatus = inspectDirectory(effectiveRawPath); + const outputStatus = inspectOutputFile(effectiveOutputPath); + const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null; + const movieDirStatus = inspectDirectory(movieDirPath); const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null); const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null); @@ -215,6 +334,8 @@ function enrichJobRow(job) { return { ...job, + raw_path: effectiveRawPath, + output_path: effectiveOutputPath, makemkvInfo, handbrakeInfo, mediainfoInfo, @@ -547,19 +668,22 @@ class HistoryService { const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - const jobs = await db.all( - ` + const [jobs, settings] = await Promise.all([ + db.all( + ` SELECT j.* FROM jobs j ${whereClause} ORDER BY j.created_at DESC LIMIT 500 `, - values - ); + values + ), + settingsService.getSettingsMap() + ]); return jobs.map((job) => ({ - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: hasProcessLogFile(job.id) ? 1 : 0 })); } @@ -575,57 +699,68 @@ class HistoryService { return []; } - const db = await getDb(); - const placeholders = ids.map(() => '?').join(', '); - const rows = await db.all( - `SELECT * FROM jobs WHERE id IN (${placeholders})`, - ids - ); + const [rows, settings] = await Promise.all([ + (async () => { + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids); + })(), + settingsService.getSettingsMap() + ]); const byId = new Map(rows.map((row) => [Number(row.id), row])); return ids .map((id) => byId.get(id)) .filter(Boolean) .map((job) => ({ - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: hasProcessLogFile(job.id) ? 1 : 0 })); } async getRunningJobs() { const db = await getDb(); - const rows = await db.all( - ` + const [rows, settings] = await Promise.all([ + db.all( + ` SELECT * FROM jobs WHERE status IN ('RIPPING', 'ENCODING') ORDER BY updated_at ASC, id ASC ` - ); + ), + settingsService.getSettingsMap() + ]); return rows.map((job) => ({ - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: hasProcessLogFile(job.id) ? 1 : 0 })); } async getRunningEncodeJobs() { const db = await getDb(); - const rows = await db.all( - ` + const [rows, settings] = await Promise.all([ + db.all( + ` SELECT * FROM jobs WHERE status = 'ENCODING' ORDER BY updated_at ASC, id ASC ` - ); + ), + settingsService.getSettingsMap() + ]); return rows.map((job) => ({ - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: hasProcessLogFile(job.id) ? 1 : 0 })); } async getJobWithLogs(jobId, options = {}) { const db = await getDb(); - const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + const [job, settings] = await Promise.all([ + db.get('SELECT * FROM jobs WHERE id = ?', [jobId]), + settingsService.getSettingsMap() + ]); if (!job) { return null; } @@ -643,7 +778,7 @@ class HistoryService { if (!shouldLoadLogs) { return { - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: baseLogCount, logs: [], log: '', @@ -662,7 +797,7 @@ class HistoryService { }); return { - ...enrichJobRow(job), + ...enrichJobRow(job, settings), log_count: processLog.exists ? processLog.total : 0, logs: [], log: processLog.lines.join('\n'), @@ -909,7 +1044,7 @@ class HistoryService { }); const imported = await this.getJobById(created.id); - return enrichJobRow(imported); + return enrichJobRow(imported, settings); } async assignOmdbMetadata(jobId, payload = {}) { @@ -967,8 +1102,11 @@ class HistoryService { : `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` ); - const updated = await this.getJobById(jobId); - return enrichJobRow(updated); + const [updated, settings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); } async deleteJobFiles(jobId, target = 'both') { @@ -987,6 +1125,12 @@ class HistoryService { } const settings = await settingsService.getSettingsMap(); + const effectiveRawPath = settings.raw_dir && job.raw_path + ? resolveEffectiveRawPath(job.raw_path, settings.raw_dir) + : job.raw_path; + const effectiveOutputPath = settings.movie_dir && job.output_path + ? resolveEffectiveOutputPath(job.output_path, settings.movie_dir) + : job.output_path; const summary = { target, raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }, @@ -995,16 +1139,16 @@ class HistoryService { if (target === 'raw' || target === 'both') { summary.raw.attempted = true; - if (!job.raw_path) { + if (!effectiveRawPath) { summary.raw.reason = 'Kein raw_path im Job gesetzt.'; - } else if (!isPathInside(settings.raw_dir, job.raw_path)) { - const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${job.raw_path}`); + } else if (!isPathInside(settings.raw_dir, effectiveRawPath)) { + const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`); error.statusCode = 400; throw error; - } else if (!fs.existsSync(job.raw_path)) { + } else if (!fs.existsSync(effectiveRawPath)) { summary.raw.reason = 'RAW-Pfad existiert nicht.'; } else { - const result = deleteFilesRecursively(job.raw_path, true); + const result = deleteFilesRecursively(effectiveRawPath, true); summary.raw.deleted = true; summary.raw.filesDeleted = result.filesDeleted; summary.raw.dirsRemoved = result.dirsRemoved; @@ -1013,16 +1157,16 @@ class HistoryService { if (target === 'movie' || target === 'both') { summary.movie.attempted = true; - if (!job.output_path) { + if (!effectiveOutputPath) { summary.movie.reason = 'Kein output_path im Job gesetzt.'; - } else if (!isPathInside(settings.movie_dir, job.output_path)) { - const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${job.output_path}`); + } else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) { + const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`); error.statusCode = 400; throw error; - } else if (!fs.existsSync(job.output_path)) { + } else if (!fs.existsSync(effectiveOutputPath)) { summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; } else { - const outputPath = normalizeComparablePath(job.output_path); + const outputPath = normalizeComparablePath(effectiveOutputPath); const movieRoot = normalizeComparablePath(settings.movie_dir); const stat = fs.lstatSync(outputPath); if (stat.isDirectory()) { @@ -1061,10 +1205,13 @@ class HistoryService { ); logger.info('job:delete-files', { jobId, summary }); - const updated = await this.getJobById(jobId); + const [updated, enrichSettings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); return { summary, - job: enrichJobRow(updated) + job: enrichJobRow(updated, enrichSettings) }; } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 50b1d74..ab9dbdd 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -19,8 +19,27 @@ const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/pl const { errorToMeta } = require('../utils/errorMeta'); const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']); -const REVIEW_REFRESH_SETTING_PREFIXES = ['handbrake_', 'mediainfo_']; -const REVIEW_REFRESH_SETTING_KEYS = new Set(['makemkv_min_length_minutes']); +const REVIEW_REFRESH_SETTING_PREFIXES = [ + 'handbrake_', + 'mediainfo_', + 'makemkv_rip_', + 'makemkv_analyze_', + 'output_extension_', + 'filename_template_', + 'output_folder_template_' +]; +const REVIEW_REFRESH_SETTING_KEYS = new Set([ + 'makemkv_min_length_minutes', + 'handbrake_preset', + 'handbrake_extra_args', + 'mediainfo_extra_args', + 'makemkv_rip_mode', + 'makemkv_analyze_extra_args', + 'makemkv_rip_extra_args', + 'output_extension', + 'filename_template', + 'output_folder_template' +]); const QUEUE_ACTIONS = { START_PREPARED: 'START_PREPARED', RETRY: 'RETRY', @@ -35,11 +54,122 @@ const QUEUE_ACTION_LABELS = { [QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten', [QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen' }; +const PRE_ENCODE_PROGRESS_RESERVE = 10; +const POST_ENCODE_PROGRESS_RESERVE = 10; +const POST_ENCODE_FINISH_BUFFER = 1; function nowIso() { return new Date().toISOString(); } +function normalizeMediaProfile(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + return 'bluray'; + } + if (raw === 'dvd') { + return 'dvd'; + } + if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { + return 'other'; + } + return null; +} + +function inferMediaProfileFromRawPath(rawPath) { + const source = String(rawPath || '').trim(); + if (!source) { + return null; + } + const bdmvPath = path.join(source, 'BDMV'); + const bdmvStreamPath = path.join(bdmvPath, 'STREAM'); + try { + if (fs.existsSync(bdmvStreamPath) || fs.existsSync(bdmvPath)) { + return 'bluray'; + } + } catch (_error) { + // ignore fs errors + } + + const videoTsPath = path.join(source, 'VIDEO_TS'); + try { + if (fs.existsSync(videoTsPath)) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + + try { + const entries = fs.readdirSync(source, { withFileTypes: true }); + const hasIso = entries.some( + (e) => e.isFile() && path.extname(e.name).toLowerCase() === '.iso' + ); + if (hasIso) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + + return null; +} + +function inferMediaProfileFromDeviceInfo(deviceInfo = null) { + const device = deviceInfo && typeof deviceInfo === 'object' + ? deviceInfo + : null; + if (!device) { + return null; + } + + const explicit = normalizeMediaProfile( + device.mediaProfile || device.profile || device.type || null + ); + if (explicit) { + return explicit; + } + + const markerText = [ + device.discLabel, + device.label, + device.fstype + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + + if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) { + return 'bluray'; + } + if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) { + return 'dvd'; + } + + const mountpoint = String(device.mountpoint || '').trim(); + if (mountpoint) { + try { + if (fs.existsSync(path.join(mountpoint, 'BDMV'))) { + return 'bluray'; + } + } catch (_error) { + // ignore fs errors + } + try { + if (fs.existsSync(path.join(mountpoint, 'VIDEO_TS'))) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + } + + return null; +} + function fileTimestamp() { const d = new Date(); const y = d.getFullYear(); @@ -227,6 +357,136 @@ function composeStatusText(stage, percent, detail) { return base; } +function clampProgressPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; + } + return Math.max(0, Math.min(100, parsed)); +} + +function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) { + const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode'; + const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript'; + const position = Number.isFinite(index) && Number.isFinite(total) && total > 0 + ? ` ${index}/${total}` + : ''; + const status = statusWord ? ` ${statusWord}` : ''; + const detail = String(label || '').trim(); + return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`; +} + +function createEncodeScriptProgressTracker({ + jobId, + preSteps = 0, + postSteps = 0, + updateProgress +}) { + const preTotal = Math.max(0, Math.trunc(Number(preSteps) || 0)); + const postTotal = Math.max(0, Math.trunc(Number(postSteps) || 0)); + const hasPre = preTotal > 0; + const hasPost = postTotal > 0; + const preReserve = hasPre ? PRE_ENCODE_PROGRESS_RESERVE : 0; + const postReserve = hasPost ? POST_ENCODE_PROGRESS_RESERVE : 0; + const finalPercentBeforeFinish = hasPost ? (100 - POST_ENCODE_FINISH_BUFFER) : 100; + const handBrakeStart = preReserve; + const handBrakeEnd = Math.max(handBrakeStart, finalPercentBeforeFinish - postReserve); + + let preCompleted = 0; + let postCompleted = 0; + + const clampPhasePercent = (value) => { + const clamped = clampProgressPercent(value); + if (clamped === null) { + return 0; + } + return Number(clamped.toFixed(2)); + }; + + const calculatePrePercent = () => { + if (preTotal <= 0) { + return clampPhasePercent(handBrakeStart); + } + return clampPhasePercent((preCompleted / preTotal) * preReserve); + }; + + const calculatePostPercent = () => { + if (postTotal <= 0) { + return clampPhasePercent(handBrakeEnd); + } + return clampPhasePercent(handBrakeEnd + ((postCompleted / postTotal) * postReserve)); + }; + + const callProgress = async (percent, statusText) => { + if (typeof updateProgress !== 'function') { + return; + } + await updateProgress('ENCODING', percent, null, statusText, jobId); + }; + + return { + hasScriptSteps: hasPre || hasPost, + handBrakeStart, + handBrakeEnd, + + mapHandBrakePercent(percent) { + if (!this.hasScriptSteps) { + return percent; + } + const normalized = clampProgressPercent(percent); + if (normalized === null) { + return percent; + } + const ratio = normalized / 100; + return clampPhasePercent(handBrakeStart + ((handBrakeEnd - handBrakeStart) * ratio)); + }, + + async onStepStart(phase, itemType, index, total, label) { + if (phase === 'pre' && preTotal <= 0) { + return; + } + if (phase === 'post' && postTotal <= 0) { + return; + } + const percent = phase === 'pre' + ? calculatePrePercent() + : calculatePostPercent(); + await callProgress(percent, composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, 'startet')); + }, + + async onStepComplete(phase, itemType, index, total, label, success = true) { + if (phase === 'pre' && preTotal <= 0) { + return; + } + if (phase === 'post' && postTotal <= 0) { + return; + } + + if (phase === 'pre') { + preCompleted = Math.min(preTotal, preCompleted + 1); + } else { + postCompleted = Math.min(postTotal, postCompleted + 1); + } + + const percent = phase === 'pre' + ? calculatePrePercent() + : calculatePostPercent(); + await callProgress( + percent, + composeEncodeScriptStatusText( + percent, + phase, + itemType, + index, + total, + label, + success ? 'OK' : 'Fehler' + ) + ); + } + }; +} + function shouldKeepHighlight(line) { return /error|fail|warn|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line); } @@ -997,6 +1257,7 @@ function buildDiscScanReview({ playlistAnalysis = null, selectedPlaylistId = null, selectedMakemkvTitleId = null, + mediaProfile = null, sourceArg = null, mode = 'pre_rip', preRip = true, @@ -1164,6 +1425,7 @@ function buildDiscScanReview({ return { generatedAt: nowIso(), mode, + mediaProfile: normalizeMediaProfile(mediaProfile) || null, preRip: Boolean(preRip), reviewConfirmed: false, minLengthMinutes, @@ -1964,8 +2226,42 @@ function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null return set; } +function ensureDvdBackupIso(rawDir) { + let entries; + try { + entries = fs.readdirSync(rawDir, { withFileTypes: true }); + } catch (_error) { + return null; + } + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + if (ext === '.iso') { + return path.join(rawDir, entry.name); + } + } + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + const oldPath = path.join(rawDir, entry.name); + const newName = ext ? entry.name.slice(0, -ext.length) + '.iso' : entry.name + '.iso'; + const newPath = path.join(rawDir, newName); + try { + fs.renameSync(oldPath, newPath); + return newPath; + } catch (_error) { + return null; + } + } + return null; +} + function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) { - const primary = findMediaFiles(rawPath, ['.mkv', '.mp4']); + const primary = findMediaFiles(rawPath, ['.mkv', '.mp4', '.iso']); if (primary.length > 0) { return { mediaFiles: primary, @@ -1977,6 +2273,14 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP const backupRoot = fs.existsSync(streamDir) ? streamDir : rawPath; let backupFiles = findMediaFiles(backupRoot, ['.m2ts']); if (backupFiles.length === 0) { + const videoTsDir = path.join(rawPath, 'VIDEO_TS'); + if (fs.existsSync(videoTsDir)) { + const vobFiles = findMediaFiles(videoTsDir, ['.vob']); + return { + mediaFiles: vobFiles, + source: 'dvd' + }; + } return { mediaFiles: [], source: 'none' @@ -2779,24 +3083,35 @@ class PipelineService extends EventEmitter { } async onDiscInserted(deviceInfo) { + const rawDevice = deviceInfo && typeof deviceInfo === 'object' + ? deviceInfo + : {}; + const resolvedMediaProfile = normalizeMediaProfile(rawDevice.mediaProfile) + || inferMediaProfileFromDeviceInfo(rawDevice) + || 'other'; + const resolvedDevice = { + ...rawDevice, + mediaProfile: resolvedMediaProfile + }; + const previousDevice = this.snapshot.context?.device || this.detectedDisc; const previousState = this.snapshot.state; const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null; - const discChanged = previousDevice ? !this.isSameDisc(previousDevice, deviceInfo) : false; + const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false; - this.detectedDisc = deviceInfo; - logger.info('disc:inserted', { deviceInfo }); + this.detectedDisc = resolvedDevice; + logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile }); wsService.broadcast('DISC_DETECTED', { - device: deviceInfo + device: resolvedDevice }); if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') { - const message = `Disk gewechselt (${deviceInfo.discLabel || deviceInfo.path || 'unbekannt'}). Bitte neu analysieren.`; + const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`; logger.info('disc:changed:reset', { fromState: previousState, previousDevice, - newDevice: deviceInfo, + newDevice: resolvedDevice, previousJobId }); @@ -2816,7 +3131,7 @@ class PipelineService extends EventEmitter { eta: null, statusText: 'Neue Disk erkannt', context: { - device: deviceInfo + device: resolvedDevice } }); return; @@ -2829,7 +3144,7 @@ class PipelineService extends EventEmitter { eta: null, statusText: 'Neue Disk erkannt', context: { - device: deviceInfo + device: resolvedDevice } }); } @@ -2879,6 +3194,81 @@ class PipelineService extends EventEmitter { return Number(this.snapshot.activeJobId) === Number(jobId); } + withAnalyzeContextMediaProfile(makemkvInfo, mediaProfile) { + const normalizedProfile = normalizeMediaProfile(mediaProfile); + const base = makemkvInfo && typeof makemkvInfo === 'object' + ? makemkvInfo + : {}; + return { + ...base, + analyzeContext: { + ...(base.analyzeContext || {}), + mediaProfile: normalizedProfile || null + } + }; + } + + resolveMediaProfileForJob(job, options = {}) { + const explicitProfile = normalizeMediaProfile(options?.mediaProfile); + if (explicitProfile) { + return explicitProfile; + } + + const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' + ? options.encodePlan + : null; + const profileFromPlan = normalizeMediaProfile(encodePlan?.mediaProfile); + if (profileFromPlan) { + return profileFromPlan; + } + + const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object' + ? options.makemkvInfo + : this.safeParseJson(job?.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const profileFromAnalyze = normalizeMediaProfile( + analyzeContext.mediaProfile || mkInfo?.mediaProfile + ); + if (profileFromAnalyze) { + return profileFromAnalyze; + } + + const currentContextProfile = ( + Number(this.snapshot.context?.jobId) === Number(job?.id) + ? normalizeMediaProfile(this.snapshot.context?.mediaProfile) + : null + ); + if (currentContextProfile) { + return currentContextProfile; + } + + const deviceProfile = inferMediaProfileFromDeviceInfo( + options?.deviceInfo + || this.detectedDisc + || this.snapshot.context?.device + || null + ); + if (deviceProfile) { + return deviceProfile; + } + + const rawPathProfile = inferMediaProfileFromRawPath(options?.rawPath || job?.raw_path || null); + if (rawPathProfile) { + return rawPathProfile; + } + + return 'other'; + } + + async getEffectiveSettingsForJob(job, options = {}) { + const mediaProfile = this.resolveMediaProfileForJob(job, options); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + return { + settings, + mediaProfile + }; + } + async ensureMakeMKVRegistration(jobId, stage) { const registrationConfig = await settingsService.buildMakeMKVRegisterConfig(); if (!registrationConfig) { @@ -3078,6 +3468,13 @@ class PipelineService extends EventEmitter { || device.model || 'Unknown Disc' ).trim(); + const mediaProfile = normalizeMediaProfile(device?.mediaProfile) + || inferMediaProfileFromDeviceInfo(device) + || 'other'; + const deviceWithProfile = { + ...device, + mediaProfile + }; const job = await historyService.createJob({ discDevice: device.path, @@ -3097,7 +3494,7 @@ class PipelineService extends EventEmitter { status: 'METADATA_SELECTION', last_state: 'METADATA_SELECTION', detected_title: detectedTitle, - makemkv_info_json: JSON.stringify({ + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ phase: 'PREPARE', preparedAt: nowIso(), analyzeContext: { @@ -3106,7 +3503,7 @@ class PipelineService extends EventEmitter { selectedPlaylist: null, selectedTitleId: null } - }) + }, mediaProfile)) }); await historyService.appendLog( job.id, @@ -3125,10 +3522,11 @@ class PipelineService extends EventEmitter { statusText: 'Metadaten auswählen', context: { jobId: job.id, - device, + device: deviceWithProfile, detectedTitle, detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback', omdbCandidates, + mediaProfile, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -3178,9 +3576,13 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } - - const settings = await settingsService.getSettingsMap(); const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + deviceInfo, + makemkvInfo: mkInfo + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const analyzeContext = mkInfo?.analyzeContext || {}; const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const selectedPlaylistId = normalizePlaylistId( @@ -3212,6 +3614,7 @@ class PipelineService extends EventEmitter { jobId, reviewConfirmed: false, mode: 'pre_rip', + mediaProfile, selectedMetadata } }); @@ -3220,6 +3623,7 @@ class PipelineService extends EventEmitter { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, @@ -3227,7 +3631,7 @@ class PipelineService extends EventEmitter { }); const lines = []; - const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo); + const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo, { mediaProfile }); logger.info('disc-track-review:command', { jobId, cmd: scanConfig.cmd, @@ -3259,6 +3663,7 @@ class PipelineService extends EventEmitter { playlistAnalysis, selectedPlaylistId, selectedMakemkvTitleId, + mediaProfile, sourceArg: scanConfig.sourceArg }); @@ -3302,6 +3707,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle: Boolean(review.encodeInputTitleId), reviewConfirmed: false, mode: 'pre_rip', + mediaProfile, mediaInfoReview: review, selectedMetadata } @@ -3389,8 +3795,13 @@ class PipelineService extends EventEmitter { const mode = String(options?.mode || 'rip').trim().toLowerCase() || 'rip'; const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); - const settings = await settingsService.getSettingsMap(); const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + rawPath, + makemkvInfo: mkInfo + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const analyzeContext = mkInfo?.analyzeContext || {}; let playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; let handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null); @@ -3428,6 +3839,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle: false, reviewConfirmed: false, mode, + mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata } @@ -3438,6 +3850,7 @@ class PipelineService extends EventEmitter { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, @@ -3475,7 +3888,7 @@ class PipelineService extends EventEmitter { ); } else { const analyzeLines = []; - const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath); + const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile }); logger.info('backup-track-review:makemkv-analyze-command', { jobId, cmd: analyzeConfig.cmd, @@ -3537,7 +3950,7 @@ class PipelineService extends EventEmitter { ); try { const resolveScanLines = []; - const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath); + const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); logger.info('backup-track-review:handbrake-predecision-command', { jobId, cmd: resolveScanConfig.cmd, @@ -3608,6 +4021,7 @@ class PipelineService extends EventEmitter { ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), + mediaProfile, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired, selectedPlaylist: selectedPlaylistId || null, @@ -3685,6 +4099,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle: false, reviewConfirmed: false, mode, + mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata, playlistAnalysis: playlistAnalysis || null, @@ -3811,7 +4226,7 @@ class PipelineService extends EventEmitter { } else { try { const resolveScanLines = []; - const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath); + const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); logger.info('backup-track-review:handbrake-resolve-command', { jobId, cmd: resolveScanConfig.cmd, @@ -3902,7 +4317,8 @@ class PipelineService extends EventEmitter { let presetProfile = null; try { presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { - titleId: resolvedHandBrakeTitleId + titleId: resolvedHandBrakeTitleId, + mediaProfile }); } catch (error) { logger.warn('backup-track-review:preset-profile-failed', { @@ -3963,6 +4379,7 @@ class PipelineService extends EventEmitter { review = { ...review, mode, + mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, @@ -4050,6 +4467,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle, reviewConfirmed: false, mode, + mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: review, selectedMetadata, @@ -4095,6 +4513,8 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const waitingForPlaylistSelection = ( @@ -4109,11 +4529,11 @@ class PipelineService extends EventEmitter { || (fromOmdb !== null && fromOmdb !== undefined) ); if (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) { - const currentMkInfo = this.safeParseJson(job.makemkv_info_json); + const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; const currentPlaylistAnalysis = currentAnalyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const selectedTitleId = pickTitleIdForPlaylist(currentPlaylistAnalysis, normalizedSelectedPlaylist); - const updatedMkInfo = { + const updatedMkInfo = this.withAnalyzeContextMediaProfile({ ...currentMkInfo, analyzeContext: { ...currentAnalyzeContext, @@ -4122,7 +4542,7 @@ class PipelineService extends EventEmitter { selectedPlaylist: normalizedSelectedPlaylist, selectedTitleId: selectedTitleId ?? null } - }; + }, mediaProfile); await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', @@ -4144,7 +4564,8 @@ class PipelineService extends EventEmitter { await this.runBackupTrackReviewForJob(jobId, job.raw_path, { mode: 'rip', selectedPlaylist: normalizedSelectedPlaylist, - selectedTitleId: selectedTitleId ?? null + selectedTitleId: selectedTitleId ?? null, + mediaProfile }); } catch (error) { logger.error('metadata:playlist-selection:review-failed', { @@ -4198,7 +4619,7 @@ class PipelineService extends EventEmitter { imdbId: effectiveImdbId, poster: posterValue }; - const settings = await settingsService.getSettingsMap(); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; @@ -4229,8 +4650,7 @@ class PipelineService extends EventEmitter { ); const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; - const mkInfo = this.safeParseJson(job.makemkv_info_json); - const updatedMakemkvInfo = { + const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), @@ -4239,7 +4659,7 @@ class PipelineService extends EventEmitter { selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null } - }; + }, mediaProfile); await historyService.updateJob(jobId, { title: effectiveTitle, @@ -4286,6 +4706,7 @@ class PipelineService extends EventEmitter { ...(this.snapshot.context || {}), jobId, rawPath: updatedRawPath, + mediaProfile, selectedMetadata, playlistAnalysis: playlistDecision.playlistAnalysis || null, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), @@ -4485,7 +4906,10 @@ class PipelineService extends EventEmitter { } const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); - const settings = await settingsService.getSettingsMap(); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: encodePlanForReadyState + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; @@ -4538,7 +4962,8 @@ class PipelineService extends EventEmitter { ); this.runReviewForRawJob(jobId, job.raw_path, { - mode: 'rip' + mode: 'rip', + mediaProfile }).catch((error) => { logger.error('startPreparedJob:review-background-failed', { jobId, error: errorToMeta(error) }); this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { @@ -4811,7 +5236,8 @@ class PipelineService extends EventEmitter { this.runReviewForRawJob(sourceJobId, sourceJob.raw_path, { mode: 'reencode', sourceJobId, - forcePlaylistReselection: true + forcePlaylistReselection: true, + mediaProfile: this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, rawPath: sourceJob.raw_path }) }).catch((error) => { logger.error('reencodeFromRaw:background-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(error) }); this.failJob(sourceJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { @@ -4831,9 +5257,12 @@ class PipelineService extends EventEmitter { }; } - async runMediainfoForFile(jobId, inputPath) { + async runMediainfoForFile(jobId, inputPath, options = {}) { const lines = []; - const config = await settingsService.buildMediaInfoConfig(inputPath); + const config = await settingsService.buildMediaInfoConfig(inputPath, { + mediaProfile: options?.mediaProfile || null, + settingsMap: options?.settingsMap || null + }); logger.info('mediainfo:command', { jobId, inputPath, cmd: config.cmd, args: config.args }); const runInfo = await this.runCommand({ @@ -4870,8 +5299,13 @@ class PipelineService extends EventEmitter { throw error; } - const settings = await settingsService.getSettingsMap(); const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + makemkvInfo: mkInfo, + rawPath + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const analyzeContext = mkInfo?.analyzeContext || {}; const selectedPlaylistId = normalizePlaylistId( analyzeContext.selectedPlaylist @@ -4899,7 +5333,10 @@ class PipelineService extends EventEmitter { ); let presetProfile = null; try { - presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path); + presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path, { + mediaProfile, + settingsMap: settings + }); } catch (error) { logger.warn('mediainfo:review:preset-profile-failed', { jobId, @@ -4929,6 +5366,7 @@ class PipelineService extends EventEmitter { rawPath, reviewConfirmed: false, mode: options.mode || 'rip', + mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata } @@ -4937,7 +5375,8 @@ class PipelineService extends EventEmitter { await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', - last_state: 'MEDIAINFO_CHECK' + last_state: 'MEDIAINFO_CHECK', + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)) }); const mediaInfoByPath = {}; @@ -4963,6 +5402,7 @@ class PipelineService extends EventEmitter { selectedMakemkvTitleId: preferredEncodeTitleId }), mode: options.mode || 'rip', + mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: processedFiles.length < mediaFiles.length, @@ -4976,7 +5416,10 @@ class PipelineService extends EventEmitter { const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2)); await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`); - const result = await this.runMediainfoForFile(jobId, file.path); + const result = await this.runMediainfoForFile(jobId, file.path, { + mediaProfile, + settingsMap: settings + }); mediaInfoByPath[file.path] = result.parsed; mediaInfoRuns.push({ filePath: file.path, @@ -4997,6 +5440,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle: Boolean(partialReview?.encodeInputPath), reviewConfirmed: false, mode: options.mode || 'rip', + mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: partialReview, selectedMetadata @@ -5019,6 +5463,7 @@ class PipelineService extends EventEmitter { const enrichedReview = { ...review, mode: options.mode || 'rip', + mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, @@ -5070,6 +5515,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle, reviewConfirmed: false, mode: options.mode || 'rip', + mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, selectedMetadata @@ -5085,7 +5531,7 @@ class PipelineService extends EventEmitter { return enrichedReview; } - async runEncodeChains(jobId, chainIds, context = {}, phase = 'post') { + async runEncodeChains(jobId, chainIds, context = {}, phase = 'post', progressTracker = null) { const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; if (ids.length === 0) { return { configured: 0, succeeded: 0, failed: 0, results: [] }; @@ -5093,7 +5539,12 @@ class PipelineService extends EventEmitter { const results = []; let succeeded = 0; let failed = 0; - for (const chainId of ids) { + for (let index = 0; index < ids.length; index += 1) { + const chainId = ids[index]; + const chainLabel = `#${chainId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart(phase, 'chain', index + 1, ids.length, chainLabel); + } await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`); try { const chainResult = await scriptChainService.executeChain(chainId, { @@ -5109,18 +5560,31 @@ class PipelineService extends EventEmitter { succeeded += 1; await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`); } + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete( + phase, + 'chain', + index + 1, + ids.length, + chainResult.chainName || chainLabel, + !(chainResult.aborted || chainResult.failed > 0) + ); + } results.push({ chainId, ...chainResult }); } catch (error) { failed += 1; results.push({ chainId, success: false, error: error.message }); await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`); logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete(phase, 'chain', index + 1, ids.length, chainLabel, false); + } } } return { configured: ids.length, succeeded, failed, results }; } - async runPreEncodeScripts(jobId, encodePlan, context = {}) { + async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; if (scriptIds.length === 0 && chainIds.length === 0) { @@ -5138,11 +5602,18 @@ class PipelineService extends EventEmitter { for (let index = 0; index < scriptIds.length; index += 1) { const scriptId = scriptIds[index]; const script = scriptById.get(Number(scriptId)); + const scriptLabel = script?.name || `#${scriptId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart('pre', 'script', index + 1, scriptIds.length, scriptLabel); + } if (!script) { failed += 1; aborted = true; results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, scriptLabel, false); + } break; } await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`); @@ -5168,12 +5639,18 @@ class PipelineService extends EventEmitter { succeeded += 1; results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true); + } } catch (error) { failed += 1; aborted = true; results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`); logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, false); + } break; } finally { if (prepared?.cleanup) { @@ -5183,7 +5660,7 @@ class PipelineService extends EventEmitter { } if (!aborted && chainIds.length > 0) { - const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre'); + const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre', progressTracker); if (chainResult.failed > 0) { aborted = true; failed += chainResult.failed; @@ -5213,7 +5690,7 @@ class PipelineService extends EventEmitter { }; } - async runPostEncodeScripts(jobId, encodePlan, context = {}) { + async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; if (scriptIds.length === 0 && chainIds.length === 0) { @@ -5242,6 +5719,10 @@ class PipelineService extends EventEmitter { for (let index = 0; index < scriptIds.length; index += 1) { const scriptId = scriptIds[index]; const script = scriptById.get(Number(scriptId)); + const scriptLabel = script?.name || `#${scriptId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart('post', 'script', index + 1, scriptIds.length, scriptLabel); + } if (!script) { failed += 1; aborted = true; @@ -5255,6 +5736,9 @@ class PipelineService extends EventEmitter { status: 'ERROR', error: 'missing' }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, scriptLabel, false); + } break; } @@ -5296,6 +5780,9 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Post-Encode Skript erfolgreich: ${script.name}` ); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true); + } } catch (error) { failed += 1; aborted = true; @@ -5319,6 +5806,9 @@ class PipelineService extends EventEmitter { scriptName: script.name, error: errorToMeta(error) }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, false); + } break; } finally { if (prepared?.cleanup) { @@ -5355,7 +5845,7 @@ class PipelineService extends EventEmitter { } if (!aborted && chainIds.length > 0) { - const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post'); + const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post', progressTracker); if (chainResult.failed > 0) { aborted = true; failed += chainResult.failed; @@ -5387,10 +5877,6 @@ class PipelineService extends EventEmitter { this.ensureNotBusy('startEncodingFromPrepared', jobId); logger.info('encode:start-from-prepared', { jobId }); - const settings = await settingsService.getSettingsMap(); - const movieDir = settings.movie_dir; - ensureDir(movieDir); - const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); @@ -5399,6 +5885,13 @@ class PipelineService extends EventEmitter { } const encodePlan = this.safeParseJson(job.encode_plan_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + rawPath: job.raw_path + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const movieDir = settings.movie_dir; + ensureDir(movieDir); const mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip'; let inputPath = job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null; @@ -5437,6 +5930,7 @@ class PipelineService extends EventEmitter { inputPath, outputPath: incompleteOutputPath, reviewConfirmed: true, + mediaProfile, mediaInfoReview: encodePlan || null, selectedMetadata: { title: job.title || job.detected_title || null, @@ -5477,14 +5971,35 @@ class PipelineService extends EventEmitter { jobId, jobTitle: job.title || job.detected_title || null, inputPath, - rawPath: job.raw_path || null + rawPath: job.raw_path || null, + mediaProfile }; const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; + const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + const postChainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; + const normalizedPreChainIds = Array.isArray(preChainIds) + ? preChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const normalizedPostChainIds = Array.isArray(postChainIds) + ? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const encodeScriptProgressTracker = createEncodeScriptProgressTracker({ + jobId, + preSteps: preScriptIds.length + normalizedPreChainIds.length, + postSteps: postScriptIds.length + normalizedPostChainIds.length, + updateProgress: this.updateProgress.bind(this) + }); + let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; if (preScriptIds.length > 0 || preChainIds.length > 0) { await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...'); try { - await this.runPreEncodeScripts(jobId, encodePlan, preEncodeContext); + preEncodeScriptsSummary = await this.runPreEncodeScripts( + jobId, + encodePlan, + preEncodeContext, + encodeScriptProgressTracker + ); } catch (preError) { if (preError.preEncodeFailed) { await this.failJob(jobId, 'ENCODING', preError); @@ -5527,7 +6042,10 @@ class PipelineService extends EventEmitter { ); if (!handBrakeTitleId && selectedPlaylistId) { const titleResolveScanLines = []; - const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath); + const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile, + settingsMap: settings + }); logger.info('encoding:title-resolve-scan:command', { jobId, cmd: titleResolveScanConfig.cmd, @@ -5567,7 +6085,9 @@ class PipelineService extends EventEmitter { } const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, { trackSelection, - titleId: handBrakeTitleId + titleId: handBrakeTitleId, + mediaProfile, + settingsMap: settings }); if (trackSelection) { await historyService.appendLog( @@ -5584,13 +6104,25 @@ class PipelineService extends EventEmitter { ); } logger.info('encoding:command', { jobId, cmd: handBrakeConfig.cmd, args: handBrakeConfig.args }); + const handBrakeProgressParser = encodeScriptProgressTracker.hasScriptSteps + ? (line) => { + const parsed = parseHandBrakeProgress(line); + if (!parsed || parsed.percent === null || parsed.percent === undefined) { + return parsed; + } + return { + ...parsed, + percent: encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) + }; + } + : parseHandBrakeProgress; const handbrakeInfo = await this.runCommand({ jobId, stage: 'ENCODING', source: 'HANDBRAKE', cmd: handBrakeConfig.cmd, args: handBrakeConfig.args, - parser: parseHandBrakeProgress + parser: handBrakeProgressParser }); const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, @@ -5624,7 +6156,7 @@ class PipelineService extends EventEmitter { inputPath, outputPath: finalizedOutputPath, rawPath: job.raw_path || null - }); + }, encodeScriptProgressTracker); } catch (error) { logger.warn('encode:post-script:summary-failed', { jobId, @@ -5645,6 +6177,7 @@ class PipelineService extends EventEmitter { } const handbrakeInfoWithPostScripts = { ...handbrakeInfo, + preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary }; @@ -5741,12 +6274,18 @@ class PipelineService extends EventEmitter { ? (Array.isArray(preRipPlanBeforeRip?.preEncodeChainIds) ? preRipPlanBeforeRip.preEncodeChainIds : []) .map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: preRipPlanBeforeRip, + makemkvInfo: mkInfo, + deviceInfo: this.detectedDisc || this.snapshot.context?.device || null + }); const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); const selectedTitleId = playlistDecision.selectedTitleId; const selectedPlaylist = playlistDecision.selectedPlaylist; const selectedPlaylistFile = toPlaylistFile(selectedPlaylist); - const settings = await settingsService.getSettingsMap(); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const rawBaseDir = settings.raw_dir; const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' @@ -5783,10 +6322,17 @@ class PipelineService extends EventEmitter { ensureDir(rawJobDir); logger.info('rip:raw-dir-created', { jobId, rawJobDir }); - const device = this.detectedDisc || this.snapshot.context?.device || { + const deviceCandidate = this.detectedDisc || this.snapshot.context?.device || { path: job.disc_device, index: Number(settings.makemkv_source_index || 0) }; + const deviceProfile = normalizeMediaProfile(deviceCandidate?.mediaProfile) + || inferMediaProfileFromDeviceInfo(deviceCandidate) + || mediaProfile; + const device = { + ...deviceCandidate, + mediaProfile: deviceProfile + }; const devicePath = device.path || null; await this.setState('RIPPING', { @@ -5797,6 +6343,7 @@ class PipelineService extends EventEmitter { context: { jobId, device, + mediaProfile, ripMode, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), playlistCandidates: playlistDecision.candidatePlaylists, @@ -5836,8 +6383,14 @@ class PipelineService extends EventEmitter { try { await this.ensureMakeMKVRegistration(jobId, 'RIPPING'); + const isoOutputBase = ripMode === 'backup' && mediaProfile === 'dvd' + ? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`) + : null; const ripConfig = await settingsService.buildMakeMKVRipConfig(rawJobDir, device, { - selectedTitleId: effectiveSelectedTitleId + selectedTitleId: effectiveSelectedTitleId, + mediaProfile, + settingsMap: settings, + isoOutputBase }); logger.info('rip:command', { jobId, @@ -5899,15 +6452,27 @@ class PipelineService extends EventEmitter { }); } } + if (ripMode === 'backup' && mediaProfile === 'dvd') { + const isoPath = ensureDvdBackupIso(rawJobDir); + if (isoPath) { + await historyService.appendLog(jobId, 'SYSTEM', `DVD-Backup ISO: ${path.basename(isoPath)}`); + } else { + logger.warn('rip:dvd-backup:no-iso', { jobId, rawJobDir }); + } + } + const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); await historyService.updateJob(jobId, { - makemkv_info_json: JSON.stringify({ + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...makemkvInfo, analyzeContext: mkInfoBeforeRip?.analyzeContext || null - }) + }, mediaProfile)) }); - const review = await this.runReviewForRawJob(jobId, rawJobDir, { mode: 'rip' }); + const review = await this.runReviewForRawJob(jobId, rawJobDir, { + mode: 'rip', + mediaProfile + }); logger.info('rip:review-ready', { jobId, encodeInputPath: review.encodeInputPath, @@ -5941,10 +6506,10 @@ class PipelineService extends EventEmitter { if (error.runInfo && error.runInfo.source === 'MAKEMKV_RIP') { const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); await historyService.updateJob(jobId, { - makemkv_info_json: JSON.stringify({ + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...error.runInfo, analyzeContext: mkInfoBeforeRip?.analyzeContext || null - }) + }, mediaProfile)) }); } if ( diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index b4c5c58..a8c111b 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -28,6 +28,45 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']); const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']); const LOG_DIR_SETTING_KEY = 'log_dir'; +const MEDIA_PROFILES = ['bluray', 'dvd', 'other']; +const PROFILED_SETTINGS = { + mediainfo_extra_args: { + bluray: 'mediainfo_extra_args_bluray', + dvd: 'mediainfo_extra_args_dvd' + }, + makemkv_rip_mode: { + bluray: 'makemkv_rip_mode_bluray', + dvd: 'makemkv_rip_mode_dvd' + }, + makemkv_analyze_extra_args: { + bluray: 'makemkv_analyze_extra_args_bluray', + dvd: 'makemkv_analyze_extra_args_dvd' + }, + makemkv_rip_extra_args: { + bluray: 'makemkv_rip_extra_args_bluray', + dvd: 'makemkv_rip_extra_args_dvd' + }, + handbrake_preset: { + bluray: 'handbrake_preset_bluray', + dvd: 'handbrake_preset_dvd' + }, + handbrake_extra_args: { + bluray: 'handbrake_extra_args_bluray', + dvd: 'handbrake_extra_args_dvd' + }, + output_extension: { + bluray: 'output_extension_bluray', + dvd: 'output_extension_dvd' + }, + filename_template: { + bluray: 'filename_template_bluray', + dvd: 'filename_template_dvd' + }, + output_folder_template: { + bluray: 'output_folder_template_bluray', + dvd: 'output_folder_template_dvd' + } +}; function applyRuntimeLogDirSetting(rawValue) { const resolved = setLogRootDir(rawValue); @@ -183,6 +222,37 @@ function uniquePresetEntries(entries) { return unique; } +function normalizeMediaProfileValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + return 'bluray'; + } + if (raw === 'dvd') { + return 'dvd'; + } + if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { + return 'other'; + } + return null; +} + +function resolveProfileFallbackOrder(profile) { + const normalized = normalizeMediaProfileValue(profile); + if (normalized === 'bluray') { + return ['bluray', 'dvd']; + } + if (normalized === 'dvd') { + return ['dvd', 'bluray']; + } + if (normalized === 'other') { + return ['dvd', 'bluray']; + } + return ['dvd', 'bluray']; +} + function normalizePresetListLines(rawOutput) { const lines = String(rawOutput || '').split(/\r?\n/); const normalized = []; @@ -358,6 +428,42 @@ class SettingsService { return map; } + normalizeMediaProfile(value) { + return normalizeMediaProfileValue(value); + } + + resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) { + const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const fallbackOrder = resolveProfileFallbackOrder(mediaProfile); + const resolvedMediaProfile = normalizeMediaProfileValue(mediaProfile) || fallbackOrder[0] || 'dvd'; + const effective = { + ...sourceMap, + media_profile: resolvedMediaProfile + }; + + for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) { + let resolvedValue = sourceMap[legacyKey]; + for (const profile of fallbackOrder) { + const profileKey = profileKeys?.[profile]; + if (!profileKey) { + continue; + } + if (sourceMap[profileKey] !== undefined) { + resolvedValue = sourceMap[profileKey]; + break; + } + } + effective[legacyKey] = resolvedValue; + } + + return effective; + } + + async getEffectiveSettingsMap(mediaProfile = null) { + const map = await this.getSettingsMap(); + return this.resolveEffectiveToolSettings(map, mediaProfile); + } + async getFlatSettings() { const db = await getDb(); const rows = await db.all( @@ -537,19 +643,24 @@ class SettingsService { })); } - async buildMakeMKVAnalyzeConfig(deviceInfo = null) { - const map = await this.getSettingsMap(); + async buildMakeMKVAnalyzeConfig(deviceInfo = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); const cmd = map.makemkv_command; - const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)]; + const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo), ...splitArgs(map.makemkv_analyze_extra_args)]; logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo }); return { cmd, args }; } async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) { - const map = await this.getSettingsMap(); + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); const cmd = map.makemkv_command; const sourceArg = `file:${sourcePath}`; - const args = ['-r', 'info', sourceArg]; + const args = ['-r', 'info', sourceArg, ...splitArgs(map.makemkv_analyze_extra_args)]; const titleIdRaw = Number(options?.titleId); // "makemkvcon info" supports only ; title filtering is done in app parser. logger.debug('cli:makemkv:analyze:path', { @@ -562,7 +673,11 @@ class SettingsService { } async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) { - const map = await this.getSettingsMap(); + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); const cmd = map.makemkv_command; const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' @@ -579,12 +694,16 @@ class SettingsService { ignored: parsedExtra }); } - baseArgs = [ - 'backup', - '--decrypt', - sourceArg, - rawJobDir - ]; + const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null); + const isDvd = normalizedProfile === 'dvd'; + if (isDvd) { + const isoBase = options?.isoOutputBase + ? path.join(rawJobDir, options.isoOutputBase) + : rawJobDir; + baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, isoBase]; + } else { + baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir]; + } } else { extra = parsedExtra; const minLength = Number(map.makemkv_min_length_minutes || 60); @@ -592,6 +711,7 @@ class SettingsService { const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all'; if (hasExplicitTitle) { baseArgs = [ + '-r', '--progress=-same', 'mkv', sourceArg, targetTitle, @@ -599,6 +719,7 @@ class SettingsService { ]; } else { baseArgs = [ + '-r', '--progress=-same', '--minlength=' + Math.round(minLength * 60), 'mkv', sourceArg, @@ -637,8 +758,9 @@ class SettingsService { }; } - async buildMediaInfoConfig(inputPath) { - const map = await this.getSettingsMap(); + async buildMediaInfoConfig(inputPath, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); const cmd = map.mediainfo_command || 'mediainfo'; const baseArgs = ['--Output=JSON']; const extra = splitArgs(map.mediainfo_extra_args); @@ -648,7 +770,8 @@ class SettingsService { } async buildHandBrakeConfig(inputFile, outputFile, options = {}) { - const map = await this.getSettingsMap(); + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); const cmd = map.handbrake_command; const rawTitleId = Number(options?.titleId); const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0 @@ -752,8 +875,12 @@ class SettingsService { return '/dev/sr0'; } - async buildHandBrakeScanConfig(deviceInfo = null) { - const map = await this.getSettingsMap(); + async buildHandBrakeScanConfig(deviceInfo = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); const cmd = map.handbrake_command || 'HandBrakeCLI'; const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo); // Match legacy rip.sh behavior: scan all titles, then decide in app logic. @@ -767,7 +894,8 @@ class SettingsService { } async buildHandBrakeScanConfigForInput(inputPath, options = {}) { - const map = await this.getSettingsMap(); + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); const cmd = map.handbrake_command || 'HandBrakeCLI'; // RAW backup folders must be scanned as full BD source to get usable title list. const rawTitleId = Number(options?.titleId); @@ -785,7 +913,8 @@ class SettingsService { } async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) { - const map = await this.getSettingsMap(); + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); const cmd = map.handbrake_command || 'HandBrakeCLI'; const presetName = map.handbrake_preset || null; const rawTitleId = Number(options?.titleId); @@ -917,10 +1046,12 @@ class SettingsService { async getHandBrakePresetOptions() { const map = await this.getSettingsMap(); - const configuredPreset = String(map.handbrake_preset || '').trim(); - const fallbackOptions = configuredPreset - ? [{ label: configuredPreset, value: configuredPreset }] - : []; + const configuredPresets = uniqueOrderedValues([ + map.handbrake_preset_bluray, + map.handbrake_preset_dvd, + map.handbrake_preset + ]); + const fallbackOptions = configuredPresets.map((preset) => ({ label: preset, value: preset })); const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim(); const commandTokens = splitArgs(rawCommand); const cmd = commandTokens[0] || 'HandBrakeCLI'; @@ -963,7 +1094,7 @@ class SettingsService { options: fallbackOptions }; } - if (!configuredPreset) { + if (configuredPresets.length === 0) { return { source: 'handbrake-cli', message: null, @@ -971,8 +1102,10 @@ class SettingsService { }; } - const hasConfiguredPreset = options.some((option) => option.value === configuredPreset); - if (hasConfiguredPreset) { + const missingConfiguredPresets = configuredPresets.filter( + (preset) => !options.some((option) => option.value === preset) + ); + if (missingConfiguredPresets.length === 0) { return { source: 'handbrake-cli', message: null, @@ -982,8 +1115,11 @@ class SettingsService { return { source: 'handbrake-cli', - message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`, - options: [{ label: configuredPreset, value: configuredPreset }, ...options] + message: `Konfigurierte Presets wurden in HandBrakeCLI -z nicht gefunden: ${missingConfiguredPresets.join(', ')}`, + options: [ + ...missingConfiguredPresets.map((preset) => ({ label: preset, value: preset })), + ...options + ] }; } catch (error) { return { diff --git a/backend/src/utils/progressParsers.js b/backend/src/utils/progressParsers.js index 6817f89..684b699 100644 --- a/backend/src/utils/progressParsers.js +++ b/backend/src/utils/progressParsers.js @@ -32,16 +32,13 @@ function parseEta(line) { function parseMakeMkvProgress(line) { const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/); if (prgv) { - const a = Number(prgv[1]); - const b = Number(prgv[2]); - const c = Number(prgv[3]); + // Format: PRGV:current,total,max (official makemkv docs) + // progress = current / max + const current = Number(prgv[1]); + const max = Number(prgv[3]); - if (c > 0) { - return { percent: clampPercent((a / c) * 100), eta: null }; - } - - if (b > 0) { - return { percent: clampPercent((a / b) * 100), eta: null }; + if (max > 0) { + return { percent: clampPercent((current / max) * 100), eta: null }; } } diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index b9c4af2..0d3b9e0 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -106,16 +106,24 @@ mediainfo --Version Ripster benötigt ein physisches **DVD- oder Blu-ray-Laufwerk**. -!!! info "Blu-ray unter Linux" - Für Blu-ray-Ripping unter Linux wird zusätzlich `libaacs` benötigt. MakeMKV bringt jedoch eine eigene Entschlüsselung mit, daher ist dies in den meisten Fällen nicht erforderlich. +!!! danger "LibDriveIO-Modus erforderlich" + Das Laufwerk muss im **LibDriveIO-Modus** betrieben werden – MakeMKV greift direkt auf Rohdaten des Laufwerks zu. Ohne diesen Modus können verschlüsselte Blu-rays (insbesondere UHD) nicht gelesen werden. + + Nicht alle Laufwerke unterstützen den direkten Zugriff. Eine Anleitung zur Einrichtung und Liste kompatibler Laufwerke findet sich im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=18856). ```bash # Laufwerk prüfen ls /dev/sr* # oder lsblk | grep rom + +# Laufwerk-Berechtigungen setzen (erforderlich für LibDriveIO) +sudo chmod a+rw /dev/sr0 ``` +!!! info "Blu-ray unter Linux" + MakeMKV bringt mit LibDriveIO eine eigene Entschlüsselung mit – externe Bibliotheken wie `libaacs` sind in der Regel nicht erforderlich. + --- ## OMDb API-Key diff --git a/docs/tools/makemkv.md b/docs/tools/makemkv.md index f4ee6ca..646b4b5 100644 --- a/docs/tools/makemkv.md +++ b/docs/tools/makemkv.md @@ -66,6 +66,48 @@ Ripster's `progressParsers.js` parst diese Ausgabe für die Live-Fortschrittsanz --- +## LibDriveIO-Modus (Pflicht) + +!!! danger "Laufwerk muss im LibDriveIO-Modus betrieben werden" + MakeMKV greift auf Discs über **LibDriveIO** zu – eine Bibliothek, die direkt auf Rohdaten des Laufwerks zugreift und den Standard-OS-Treiber umgeht. Ohne diesen Modus kann MakeMKV verschlüsselte Blu-rays (insbesondere UHD) **nicht lesen**. + +### Was ist LibDriveIO? + +LibDriveIO ist MakeMKVs interne Treiberschicht für den direkten Laufwerkszugriff. Sie ermöglicht: + +- Lesen von verschlüsselten Blu-ray-Sektoren (AACS, BD+, AACS2) +- Zugriff auf Disc-Strukturen, die über Standard-OS-APIs nicht erreichbar sind +- UHD-Blu-ray-Entschlüsselung ohne externe Bibliotheken + +### Voraussetzungen für den LibDriveIO-Modus + +Das Laufwerk muss **LibDriveIO-kompatibel** sein und entsprechend betrieben werden: + +1. **Kompatibles Laufwerk** – Nicht alle Laufwerke unterstützen den Rohdatenzugriff. UHD-kompatible Laufwerke (z. B. LG, Pioneer bestimmter Firmware-Versionen) sind erforderlich. + +2. **Laufwerk-Berechtigungen** – Der Prozess benötigt direkten Zugriff auf das Blockdevice: + ```bash + sudo chmod a+rw /dev/sr0 + # oder dauerhaft über udev-Regel + ``` + +3. **Kein OS-seitiger Disc-Mount** – Das Laufwerk darf beim Ripping **nicht** durch das OS automatisch gemountet sein (AutoMount deaktivieren): + ```bash + # Automount temporär deaktivieren (GNOME) + gsettings set org.gnome.desktop.media-handling automount false + ``` + +### How-To: LibDriveIO einrichten + +Die vollständige Anleitung zur Einrichtung und zu kompatiblen Laufwerken findet sich im offiziellen MakeMKV-Forum: + +[:octicons-link-external-24: MakeMKV Forum – LibDriveIO How-To](https://www.makemkv.com/forum/viewtopic.php?t=18856){ .md-button } + +!!! tip "Prüfen ob LibDriveIO aktiv ist" + In der MakeMKV-Ausgabe erscheint beim Laufwerkszugriff `LibDriveIO` statt `LibMMMBD`, wenn der direkte Modus aktiv ist. + +--- + ## MakeMKV-Lizenz MakeMKV ist **Beta-Software** und kostenlos für den persönlichen Gebrauch während der Beta-Phase. Eine Beta-Lizenz ist regelmäßig im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=1053) verfügbar. diff --git a/frontend/src/assets/media-other.svg b/frontend/src/assets/media-other.svg new file mode 100644 index 0000000..1a38c45 --- /dev/null +++ b/frontend/src/assets/media-other.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index b2a171f..d06ef57 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -14,39 +14,41 @@ function normalizeSettingKey(value) { return String(value || '').trim().toLowerCase(); } +const GENERAL_TOOL_KEYS = new Set([ + 'makemkv_command', + 'makemkv_registration_key', + 'makemkv_min_length_minutes', + 'mediainfo_command', + 'handbrake_command', + 'handbrake_restart_delete_incomplete_output' +]); + +const HANDBRAKE_PRESET_SETTING_KEYS = new Set([ + 'handbrake_preset', + 'handbrake_preset_bluray', + 'handbrake_preset_dvd' +]); + function buildToolSections(settings) { const list = Array.isArray(settings) ? settings : []; - const definitions = [ - { - id: 'makemkv', - title: 'MakeMKV', - description: 'Disc-Analyse und Rip-Einstellungen.', - match: (key) => key.startsWith('makemkv_') - }, - { - id: 'mediainfo', - title: 'MediaInfo', - description: 'Track-Analyse und zusätzliche mediainfo Parameter.', - match: (key) => key.startsWith('mediainfo_') - }, - { - id: 'handbrake', - title: 'HandBrake', - description: 'Preset, Encoding-CLI und HandBrake-Optionen.', - match: (key) => key.startsWith('handbrake_') - }, - { - id: 'output', - title: 'Output', - description: 'Container-Format sowie Datei- und Ordnernamen-Template.', - match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template' - } - ]; - - const buckets = definitions.map((item) => ({ - ...item, + const generalBucket = { + id: 'general', + title: 'General', + description: 'Gemeinsame Tool-Settings für alle Medien.', settings: [] - })); + }; + const blurayBucket = { + id: 'bluray', + title: 'BluRay', + description: 'Profil-spezifische Settings für Blu-ray.', + settings: [] + }; + const dvdBucket = { + id: 'dvd', + title: 'DVD', + description: 'Profil-spezifische Settings für DVD.', + settings: [] + }; const fallbackBucket = { id: 'other', title: 'Weitere Tool-Settings', @@ -56,20 +58,26 @@ function buildToolSections(settings) { for (const setting of list) { const key = normalizeSettingKey(setting?.key); - let assigned = false; - for (const bucket of buckets) { - if (bucket.match(key)) { - bucket.settings.push(setting); - assigned = true; - break; - } + if (GENERAL_TOOL_KEYS.has(key)) { + generalBucket.settings.push(setting); + continue; } - if (!assigned) { - fallbackBucket.settings.push(setting); + if (key.endsWith('_bluray')) { + blurayBucket.settings.push(setting); + continue; } + if (key.endsWith('_dvd')) { + dvdBucket.settings.push(setting); + continue; + } + fallbackBucket.settings.push(setting); } - const sections = buckets.filter((item) => item.settings.length > 0); + const sections = [ + generalBucket, + blurayBucket, + dvdBucket + ].filter((item) => item.settings.length > 0); if (fallbackBucket.settings.length > 0) { sections.push(fallbackBucket); } @@ -96,7 +104,8 @@ function buildSectionsForCategory(categoryName, settings) { } function isHandBrakePresetSetting(setting) { - return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset'; + const key = String(setting?.key || '').trim().toLowerCase(); + return HANDBRAKE_PRESET_SETTING_KEYS.has(key); } export default function DynamicSettingsForm({ diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 0961db1..4b88571 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -3,6 +3,7 @@ import { Button } from 'primereact/button'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; +import otherIndicatorIcon from '../assets/media-other.svg'; import { getStatusLabel } from '../utils/statusPresentation'; function JsonView({ title, value }) { @@ -14,9 +15,54 @@ function JsonView({ title, value }) { ); } +function ScriptResultRow({ result }) { + const status = String(result?.status || '').toUpperCase(); + const isSuccess = status === 'SUCCESS'; + const isError = status === 'ERROR'; + const isSkipped = status.startsWith('SKIPPED'); + const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle'; + const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning'; + return ( +
+ +