diff --git a/backend/package-lock.json b/backend/package-lock.json index c1302ea..0c5a147 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-3", + "version": "0.12.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-3", + "version": "0.12.0-4", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index d923591..cb22e63 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-3", + "version": "0.12.0-4", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 6a8bcdd..79939a8 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -1094,20 +1094,20 @@ async function migrateSettingsSchemaMetadata(db) { { key: 'use_plugin_architecture_dvd_review', label: 'Review / Mediainfo-Prüfung (DVD)', - description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', - defaultValue: 'false', + description: 'Review-/Track-Analyse läuft über das DVD-Plugin.', + defaultValue: 'true', orderIndex: 10013, dependsOn: 'use_plugin_architecture_dvd', - validationJson: '{"readonly":true}' + validationJson: '{}' }, { key: 'use_plugin_architecture_dvd_encode', label: 'Encoding (DVD)', - description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', - defaultValue: 'false', + description: 'Encoding läuft über das DVD-Plugin.', + defaultValue: 'true', orderIndex: 10014, dependsOn: 'use_plugin_architecture_dvd', - validationJson: '{"readonly":true}' + validationJson: '{}' }, // ── Audio-CD ───────────────────────────────────────────────────────────── { diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js index f539d31..cb594bf 100644 --- a/backend/src/plugins/VideoDiscPlugin.js +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -147,12 +147,30 @@ class VideoDiscPlugin extends SourcePlugin { }); const scanLines = []; - const runInfo = await _runCommandCaptured({ - cmd: scanConfig.cmd, - args: scanConfig.args, - onStdoutLine: (line) => scanLines.push(line), - context: { jobId: job?.id, source: `${this.id}Plugin.review` } - }); + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK'; + const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN'; + const reviewSilent = Boolean(ctx.extra?.reviewSilent); + let runInfo; + if (runCommandFn) { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: reviewStage, + source: reviewSource, + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: scanLines, + collectStderrLines: false, + silent: reviewSilent + }); + } else { + runInfo = await _runCommandCaptured({ + cmd: scanConfig.cmd, + args: scanConfig.args, + onStdoutLine: (line) => scanLines.push(line), + context: { jobId: job?.id, source: `${this.id}Plugin.review` } + }); + } ctx.logger.info(`${this.id}:review:scan-done`, { jobId: job?.id, @@ -315,16 +333,35 @@ class VideoDiscPlugin extends SourcePlugin { ctx.emitProgress(0, `${this.name}: Encoding läuft …`); - const runInfo = await _spawnAndWait({ - cmd: handBrakeConfig.cmd, - args: handBrakeConfig.args, - jobId: job?.id, - progressParser: parseHandBrakeProgress, - onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`), - isCancelled, - onProcessHandle, - context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' } - }); + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE'; + const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; + const encodeParser = typeof ctx.extra?.progressParser === 'function' + ? ctx.extra.progressParser + : parseHandBrakeProgress; + + let runInfo; + if (runCommandFn) { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: encodeStage, + source: encodeSource, + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + parser: encodeParser + }); + } else { + runInfo = await _spawnAndWait({ + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + jobId: job?.id, + progressParser: parseHandBrakeProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' } + }); + } ctx.logger.info(`${this.id}:encode:done`, { jobId: job?.id, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index aac22e6..2ea6250 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -4296,6 +4296,42 @@ class PipelineService extends EventEmitter { }); } + async runPluginReviewScan(plugin, job, { + jobId, + rawPath = null, + deviceInfo = null, + reviewMode = 'rip', + source = 'HANDBRAKE_SCAN', + silent = false + } = {}) { + if (!plugin || typeof plugin.review !== 'function') { + const error = new Error('Plugin-Review nicht verfügbar.'); + error.statusCode = 500; + throw error; + } + + const pluginCtx = await this.buildPluginContext(plugin.id, { + jobId, + rawJobDir: rawPath || null, + deviceInfo: deviceInfo || null, + reviewMode: String(reviewMode || 'rip').trim().toLowerCase() || 'rip', + runCommand: this.runCommand.bind(this), + reviewSource: String(source || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN', + reviewStage: 'MEDIAINFO_CHECK', + reviewSilent: Boolean(silent) + }); + + const pluginResult = await plugin.review(job, pluginCtx); + return { + scanLines: Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : [], + runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object' + ? pluginResult.runInfo + : null, + sourceArg: String(pluginResult?.sourceArg || '').trim() || null, + pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution()) + }; + } + isRipSuccessful(job = null) { if (Number(job?.rip_successful || 0) === 1) { return true; @@ -7247,6 +7283,13 @@ class PipelineService extends EventEmitter { makemkvInfo: mkInfo }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + const reviewPluginEnabled = Boolean( + reviewPlugin + && reviewPlugin.id === mediaProfile + && typeof reviewPlugin.review === 'function' + ); + let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const selectedPlaylistId = normalizePlaylistId( @@ -7295,24 +7338,44 @@ class PipelineService extends EventEmitter { }); const lines = []; - const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo, { mediaProfile }); - logger.info('disc-track-review:command', { - jobId, - cmd: scanConfig.cmd, - args: scanConfig.args, - sourceArg: scanConfig.sourceArg, - selectedTitleId: selectedMakemkvTitleId - }); + let runInfo = null; + let sourceArg = null; + if (reviewPluginEnabled) { + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + deviceInfo, + reviewMode: 'pre_rip', + source: 'HANDBRAKE_SCAN', + silent: !this.isPrimaryJob(jobId) + }); + lines.push(...pluginScan.scanLines); + runInfo = pluginScan.runInfo || null; + sourceArg = pluginScan.sourceArg || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + } else { + const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo, { mediaProfile }); + logger.info('disc-track-review:command', { + jobId, + cmd: scanConfig.cmd, + args: scanConfig.args, + sourceArg: scanConfig.sourceArg, + selectedTitleId: selectedMakemkvTitleId + }); - const runInfo = await this.runCommand({ - jobId, - stage: 'MEDIAINFO_CHECK', - source: 'HANDBRAKE_SCAN', - cmd: scanConfig.cmd, - args: scanConfig.args, - collectLines: lines, - collectStderrLines: false - }); + runInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN', + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: lines, + collectStderrLines: false + }); + sourceArg = scanConfig.sourceArg; + } const parsed = parseMediainfoJsonOutput(lines.join('\n')); if (!parsed) { @@ -7328,7 +7391,7 @@ class PipelineService extends EventEmitter { selectedPlaylistId, selectedMakemkvTitleId, mediaProfile, - sourceArg: scanConfig.sourceArg + sourceArg }); if (!Array.isArray(review.titles) || review.titles.length === 0) { @@ -7337,15 +7400,16 @@ class PipelineService extends EventEmitter { throw error; } + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'disc_scan', + runInfo + }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, - mediainfo_info_json: JSON.stringify({ - generatedAt: nowIso(), - source: 'disc_scan', - runInfo - }), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(review), encode_input_path: review.encodeInputPath || null, encode_review_confirmed: 0 @@ -7373,7 +7437,8 @@ class PipelineService extends EventEmitter { mode: 'pre_rip', mediaProfile, mediaInfoReview: review, - selectedMetadata + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); @@ -7467,6 +7532,13 @@ class PipelineService extends EventEmitter { makemkvInfo: mkInfo }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + const reviewPluginEnabled = Boolean( + reviewPlugin + && reviewPlugin.id === mediaProfile + && typeof reviewPlugin.review === 'function' + ); + let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; let playlistAnalysis = forceFreshAnalyze ? null @@ -7627,24 +7699,39 @@ class PipelineService extends EventEmitter { ); try { const resolveScanLines = []; - const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); - logger.info('backup-track-review:handbrake-predecision-command', { - jobId, - cmd: resolveScanConfig.cmd, - args: resolveScanConfig.args, - sourceArg: resolveScanConfig.sourceArg, - candidatePlaylists: playlistCandidates.map((item) => item.playlistFile || item.playlistId) - }); + if (reviewPluginEnabled) { + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + resolveScanLines.push(...pluginScan.scanLines); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + } else { + const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); + logger.info('backup-track-review:handbrake-predecision-command', { + jobId, + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + sourceArg: resolveScanConfig.sourceArg, + candidatePlaylists: playlistCandidates.map((item) => item.playlistFile || item.playlistId) + }); - await this.runCommand({ - jobId, - stage: 'MEDIAINFO_CHECK', - source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', - cmd: resolveScanConfig.cmd, - args: resolveScanConfig.args, - collectLines: resolveScanLines, - collectStderrLines: false - }); + await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + collectLines: resolveScanLines, + collectStderrLines: false + }); + } const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); if (resolveScanJson) { @@ -7666,6 +7753,11 @@ class PipelineService extends EventEmitter { ); } } else { + if (reviewPluginEnabled) { + const error = new Error('HandBrake Playlist-Trackdaten konnten nicht geparst werden (Plugin-Review aktiv, kein Fallback).'); + error.runInfo = null; + throw error; + } await historyService.appendLog( jobId, 'SYSTEM', @@ -7682,6 +7774,9 @@ class PipelineService extends EventEmitter { 'SYSTEM', `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}` ); + if (reviewPluginEnabled) { + throw error; + } } } else { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); @@ -7694,7 +7789,7 @@ class PipelineService extends EventEmitter { } } - const updatedMakemkvInfo = { + const updatedMakemkvInfoBase = { ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), @@ -7720,6 +7815,10 @@ class PipelineService extends EventEmitter { error: null } }; + const buildUpdatedMakemkvInfo = () => this.withPluginExecutionMeta(updatedMakemkvInfoBase, reviewPluginExecution); + const getMergedReviewPluginExecution = () => this.sanitizePluginExecutionState( + buildUpdatedMakemkvInfo()?.pluginExecution + ); if (playlistDecisionRequired && !selectedPlaylistId) { const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) @@ -7727,17 +7826,18 @@ class PipelineService extends EventEmitter { : []; const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); const decisionContext = describePlaylistManualDecision(playlistAnalysis); + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'makemkv_backup_robot', + runInfo: makeMkvAnalyzeRunInfo + }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'WAITING_FOR_USER_DECISION', last_state: 'WAITING_FOR_USER_DECISION', error_message: null, - makemkv_info_json: JSON.stringify(updatedMakemkvInfo), - mediainfo_info_json: JSON.stringify({ - generatedAt: nowIso(), - source: 'makemkv_backup_robot', - runInfo: makeMkvAnalyzeRunInfo - }), + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 @@ -7764,6 +7864,7 @@ class PipelineService extends EventEmitter { `Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}` ); + const mergedPluginExecutionForWaiting = getMergedReviewPluginExecution(); await this.setState('WAITING_FOR_USER_DECISION', { activeJobId: jobId, progress: 0, @@ -7787,7 +7888,8 @@ class PipelineService extends EventEmitter { selectedTitleId: null, waitingForManualPlaylistSelection: true, manualDecisionState: 'awaiting_playlist_selection', - mediaInfoReview: null + mediaInfoReview: null, + ...(mergedPluginExecutionForWaiting ? { pluginExecution: mergedPluginExecutionForWaiting } : {}) } }); @@ -7849,9 +7951,9 @@ class PipelineService extends EventEmitter { throw error; } - if (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) { - updatedMakemkvInfo.analyzeContext.selectedTitleId = selectedTitleForReview; - updatedMakemkvInfo.analyzeContext.selectedPlaylist = resolvedPlaylistId; + if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { + updatedMakemkvInfoBase.analyzeContext.selectedTitleId = selectedTitleForReview; + updatedMakemkvInfoBase.analyzeContext.selectedPlaylist = resolvedPlaylistId; } const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId); @@ -7922,26 +8024,42 @@ class PipelineService extends EventEmitter { } else { try { const resolveScanLines = []; - const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); - logger.info('backup-track-review:handbrake-resolve-command', { - jobId, - cmd: resolveScanConfig.cmd, - args: resolveScanConfig.args, - sourceArg: resolveScanConfig.sourceArg, - selectedPlaylistId: resolvedPlaylistId, - selectedMakemkvTitleId: selectedTitleForReview - }); + if (reviewPluginEnabled) { + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + resolveScanLines.push(...pluginScan.scanLines); + handBrakeResolveRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + } else { + const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile }); + logger.info('backup-track-review:handbrake-resolve-command', { + jobId, + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + sourceArg: resolveScanConfig.sourceArg, + selectedPlaylistId: resolvedPlaylistId, + selectedMakemkvTitleId: selectedTitleForReview + }); - handBrakeResolveRunInfo = await this.runCommand({ - jobId, - stage: 'MEDIAINFO_CHECK', - source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', - cmd: resolveScanConfig.cmd, - args: resolveScanConfig.args, - collectLines: resolveScanLines, - collectStderrLines: false, - silent: !this.isPrimaryJob(jobId) - }); + handBrakeResolveRunInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + collectLines: resolveScanLines, + collectStderrLines: false, + silent: !this.isPrimaryJob(jobId) + }); + } const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); if (!resolveScanJson) { @@ -8029,8 +8147,8 @@ class PipelineService extends EventEmitter { } } - if (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) { - updatedMakemkvInfo.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null; + if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { + updatedMakemkvInfoBase.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null; } playlistCandidates = buildPlaylistCandidates(playlistAnalysis); @@ -8168,20 +8286,22 @@ class PipelineService extends EventEmitter { throw error; } + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'raw_backup_handbrake_playlist_scan', + makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, + makemkvTitleAnalyzeRunInfo: null, + handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo, + handbrakeTitleRunInfo: handBrakeTitleRunInfo, + handbrakeTitleId: resolvedHandBrakeTitleId || null + }, reviewPluginExecution); + await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, - makemkv_info_json: JSON.stringify(updatedMakemkvInfo), - mediainfo_info_json: JSON.stringify({ - generatedAt: nowIso(), - source: 'raw_backup_handbrake_playlist_scan', - makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, - makemkvTitleAnalyzeRunInfo: null, - handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo, - handbrakeTitleRunInfo: handBrakeTitleRunInfo, - handbrakeTitleId: resolvedHandBrakeTitleId || null - }), + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(review), encode_input_path: review.encodeInputPath || null, encode_review_confirmed: 0 @@ -8222,6 +8342,7 @@ class PipelineService extends EventEmitter { const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); if (this.isPrimaryJob(jobId)) { + const mergedPluginExecutionForReady = getMergedReviewPluginExecution(); await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, @@ -8247,7 +8368,8 @@ class PipelineService extends EventEmitter { playlistDecisionRequired, playlistCandidates, selectedPlaylist: resolvedPlaylistId || null, - selectedTitleId: selectedTitleForReview + selectedTitleId: selectedTitleForReview, + ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {}) } }); } @@ -9744,6 +9866,13 @@ class PipelineService extends EventEmitter { rawPath }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + const reviewPluginEnabled = Boolean( + reviewPlugin + && reviewPlugin.id === mediaProfile + && typeof reviewPlugin.review === 'function' + ); + let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; const selectedPlaylistId = normalizePlaylistId( analyzeContext.selectedPlaylist @@ -9875,26 +10004,43 @@ class PipelineService extends EventEmitter { await this.updateProgress('MEDIAINFO_CHECK', 10, null, 'HandBrake DVD-Scan läuft', jobId); const dvdScanLines = []; - const dvdScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdHandBrakeScanInputPath, { - mediaProfile, - settingsMap: settings - }); - logger.info('mediainfo:review:dvd-handbrake-scan:command', { - jobId, - dvdHandBrakeScanInputPath, - cmd: dvdScanConfig.cmd, - args: dvdScanConfig.args - }); + let dvdScanRunInfo = null; + if (reviewPluginEnabled) { + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath: dvdHandBrakeScanInputPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_DVD', + silent: !this.isPrimaryJob(jobId) + }); + dvdScanLines.push(...pluginScan.scanLines); + dvdScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + } else { + const dvdScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdHandBrakeScanInputPath, { + mediaProfile, + settingsMap: settings + }); + logger.info('mediainfo:review:dvd-handbrake-scan:command', { + jobId, + dvdHandBrakeScanInputPath, + cmd: dvdScanConfig.cmd, + args: dvdScanConfig.args + }); - const dvdScanRunInfo = await this.runCommand({ - jobId, - stage: 'MEDIAINFO_CHECK', - source: 'HANDBRAKE_SCAN_DVD', - cmd: dvdScanConfig.cmd, - args: dvdScanConfig.args, - collectLines: dvdScanLines, - collectStderrLines: false - }); + dvdScanRunInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_DVD', + cmd: dvdScanConfig.cmd, + args: dvdScanConfig.args, + collectLines: dvdScanLines, + collectStderrLines: false + }); + } dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); if (dvdHandBrakeScanJson) { @@ -9911,6 +10057,10 @@ class PipelineService extends EventEmitter { 'SYSTEM', `HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}` ); + } else if (reviewPluginEnabled) { + const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt (Plugin-Review aktiv, kein Fallback).'); + error.runInfo = dvdScanRunInfo; + throw error; } else { await historyService.appendLog( jobId, @@ -9919,6 +10069,10 @@ class PipelineService extends EventEmitter { ); dvdHandBrakeScanJson = null; } + } else if (reviewPluginEnabled) { + const error = new Error('HandBrake DVD-Scan: Ausgabe nicht lesbar (Plugin-Review aktiv, kein Fallback).'); + error.runInfo = dvdScanRunInfo; + throw error; } else { await historyService.appendLog( jobId, @@ -9946,7 +10100,8 @@ class PipelineService extends EventEmitter { mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: partialReview, - selectedMetadata + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } @@ -10106,25 +10261,42 @@ class PipelineService extends EventEmitter { ); await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId); const dvdTitleScanLines = []; - const dvdTitleScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdScanInputPath, { - mediaProfile, - settingsMap: settings - }); - logger.info('mediainfo:review:dvd-title-scan:command', { - jobId, - cmd: dvdTitleScanConfig.cmd, - args: dvdTitleScanConfig.args, - dvdScanInputPath - }); - await this.runCommand({ - jobId, - stage: 'MEDIAINFO_CHECK', - source: 'HANDBRAKE_SCAN_DVD_TITLES', - cmd: dvdTitleScanConfig.cmd, - args: dvdTitleScanConfig.args, - collectLines: dvdTitleScanLines, - collectStderrLines: false - }); + let dvdTitleScanRunInfo = null; + if (reviewPluginEnabled) { + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath: dvdScanInputPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_DVD_TITLES', + silent: !this.isPrimaryJob(jobId) + }); + dvdTitleScanLines.push(...pluginScan.scanLines); + dvdTitleScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + } else { + const dvdTitleScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdScanInputPath, { + mediaProfile, + settingsMap: settings + }); + logger.info('mediainfo:review:dvd-title-scan:command', { + jobId, + cmd: dvdTitleScanConfig.cmd, + args: dvdTitleScanConfig.args, + dvdScanInputPath + }); + dvdTitleScanRunInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_DVD_TITLES', + cmd: dvdTitleScanConfig.cmd, + args: dvdTitleScanConfig.args, + collectLines: dvdTitleScanLines, + collectStderrLines: false + }); + } dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); if (dvdTitleScanJson) { const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); @@ -10163,14 +10335,15 @@ class PipelineService extends EventEmitter { handBrakeDvdInputPath: dvdScanInputPath, encodeInputPath: null }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'WAITING_FOR_USER_DECISION', last_state: 'WAITING_FOR_USER_DECISION', error_message: null, - mediainfo_info_json: JSON.stringify({ - generatedAt: nowIso(), - files: mediaInfoRuns - }), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), encode_plan_json: JSON.stringify(enrichedReview), encode_input_path: null, encode_review_confirmed: 0 @@ -10197,7 +10370,8 @@ class PipelineService extends EventEmitter { mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, - selectedMetadata + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } @@ -10214,6 +10388,10 @@ class PipelineService extends EventEmitter { ); enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; } + } else if (reviewPluginEnabled) { + const error = new Error('HandBrake DVD Titel-Scan: Ausgabe nicht lesbar (Plugin-Review aktiv, kein Fallback).'); + error.runInfo = dvdTitleScanRunInfo; + throw error; } else { logger.warn('mediainfo:review:dvd-title-scan:parse-failed', { jobId }); await historyService.appendLog( @@ -10235,14 +10413,16 @@ class PipelineService extends EventEmitter { ]; } + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, - mediainfo_info_json: JSON.stringify({ - generatedAt: nowIso(), - files: mediaInfoRuns - }), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(enrichedReview), encode_input_path: enrichedReview.encodeInputPath || null, encode_review_confirmed: 0 @@ -10284,7 +10464,8 @@ class PipelineService extends EventEmitter { mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, - selectedMetadata + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } @@ -10745,6 +10926,13 @@ class PipelineService extends EventEmitter { rawPath: job.raw_path }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const encodePlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'encode').catch(() => null); + const encodePluginEnabled = Boolean( + encodePlugin + && encodePlugin.id === mediaProfile + && typeof encodePlugin.encode === 'function' + ); + let encodePluginExecution = null; const resolvedRawPath = this.resolveCurrentRawPathForSettings(settings, mediaProfile, job.raw_path); const activeRawPath = resolvedRawPath || String(job.raw_path || '').trim() || null; if (activeRawPath && normalizeComparablePath(activeRawPath) !== normalizeComparablePath(job.raw_path)) { @@ -11052,13 +11240,11 @@ class PipelineService extends EventEmitter { ); } } - const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, { - trackSelection, - titleId: handBrakeTitleId, - mediaProfile, - settingsMap: settings, - userPreset: encodePlan?.userPreset || null - }); + const effectiveEncodePlan = { + ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), + trackSelection: trackSelection || null, + handBrakeTitleId: handBrakeTitleId || null + }; if (trackSelection) { await historyService.appendLog( jobId, @@ -11073,7 +11259,6 @@ class PipelineService extends EventEmitter { `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}` ); } - logger.info('encoding:command', { jobId, cmd: handBrakeConfig.cmd, args: handBrakeConfig.args }); const handBrakeProgressParser = encodeScriptProgressTracker.hasScriptSteps ? (line) => { const parsed = parseHandBrakeProgress(line); @@ -11086,14 +11271,46 @@ class PipelineService extends EventEmitter { }; } : parseHandBrakeProgress; - const handbrakeInfo = await this.runCommand({ - jobId, - stage: 'ENCODING', - source: 'HANDBRAKE', - cmd: handBrakeConfig.cmd, - args: handBrakeConfig.args, - parser: handBrakeProgressParser - }); + let handbrakeInfo = null; + if (encodePluginEnabled) { + const encodeCtx = await this.buildPluginContext(encodePlugin.id, { + jobId, + inputPath, + outputPath: incompleteOutputPath, + encodePlan: effectiveEncodePlan, + runCommand: this.runCommand.bind(this), + progressParser: handBrakeProgressParser, + encodeSource: 'HANDBRAKE', + encodeStage: 'ENCODING' + }); + handbrakeInfo = await encodePlugin.encode(job, encodeCtx); + encodePluginExecution = this.mergePluginExecutionState( + encodePluginExecution, + this.sanitizePluginExecutionState(encodeCtx.getPluginExecution()) + ); + logger.info('plugin:encode:used', { + jobId, + pluginId: encodePlugin.id, + mediaProfile + }); + } else { + const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, { + trackSelection, + titleId: handBrakeTitleId, + mediaProfile, + settingsMap: settings, + userPreset: encodePlan?.userPreset || null + }); + logger.info('encoding:command', { jobId, cmd: handBrakeConfig.cmd, args: handBrakeConfig.args }); + handbrakeInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'HANDBRAKE', + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + parser: handBrakeProgressParser + }); + } const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, preferredFinalOutputPath @@ -11192,11 +11409,11 @@ class PipelineService extends EventEmitter { } } - const handbrakeInfoWithPostScripts = { + const handbrakeInfoWithPostScripts = this.withPluginExecutionMeta({ ...handbrakeInfo, preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary - }; + }, encodePluginExecution); await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), @@ -11511,12 +11728,11 @@ class PipelineService extends EventEmitter { } const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); - // Nach dem Plugin-Rip hat jobProgress die aktuelle pluginExecution (analyze + rip). - // Nach Legacy-Rip nehmen wir die analyze-Phase aus der DB. - const postRipPluginExecution = this.sanitizePluginExecutionState( + // Merge bestehende Analyze-Marker aus der DB mit den Live-Markern aus dem Rip-Lauf, + // damit analyze nicht verloren geht, wenn jobProgress nur die neueste Rip-Phase enthält. + const postRipPluginExecution = this.mergePluginExecutionState( + mkInfoBeforeRip?.pluginExecution, this.jobProgress.get(Number(jobId))?.context?.pluginExecution - ?? mkInfoBeforeRip?.pluginExecution - ?? null ); await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ diff --git a/db/schema.sql b/db/schema.sql index 797e5e8..c8db68e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -515,12 +515,12 @@ VALUES ('use_plugin_architecture_dvd_rip', 'Erweitert', 'Rip (DVD)', 'boolean', INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_rip', 'true'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) -VALUES ('use_plugin_architecture_dvd_review', 'Erweitert', 'Review / Mediainfo-Prüfung (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10013, 'use_plugin_architecture_dvd'); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_review', 'false'); +VALUES ('use_plugin_architecture_dvd_review', 'Erweitert', 'Review / Mediainfo-Prüfung (DVD)', 'boolean', 1, 'Review-/Track-Analyse läuft über das DVD-Plugin.', 'true', '[]', '{}', 10013, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_review', 'true'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) -VALUES ('use_plugin_architecture_dvd_encode', 'Erweitert', 'Encoding (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10014, 'use_plugin_architecture_dvd'); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_encode', 'false'); +VALUES ('use_plugin_architecture_dvd_encode', 'Erweitert', 'Encoding (DVD)', 'boolean', 1, 'Encoding läuft über das DVD-Plugin.', 'true', '[]', '{}', 10014, 'use_plugin_architecture_dvd'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_encode', 'true'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Audio-CD Plugin', 'boolean', 1, 'Aktiviert das Audio-CD Plugin. Deaktiviert = Legacy-Pfad für alle CD-Phasen.', 'true', '[]', '{}', 10020, 'use_plugin_architecture'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7245d0..e10bda1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-3", + "version": "0.12.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-3", + "version": "0.12.0-4", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index db085c3..5773588 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-3", + "version": "0.12.0-4", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/utils/pluginExecution.js b/frontend/src/utils/pluginExecution.js index 185f5c1..695acfa 100644 --- a/frontend/src/utils/pluginExecution.js +++ b/frontend/src/utils/pluginExecution.js @@ -111,11 +111,12 @@ export function normalizePluginExecutionState(rawState) { export function resolveJobPluginExecution(job = null, liveContext = null, options = {}) { const liveExecution = normalizePluginExecutionState(liveContext?.pluginExecution); const makemkvExecution = normalizePluginExecutionState(job?.makemkvInfo?.pluginExecution); + const mediainfoExecution = normalizePluginExecutionState(job?.mediainfoInfo?.pluginExecution); const handbrakeExecution = normalizePluginExecutionState(job?.handbrakeInfo?.pluginExecution); const jobExecution = normalizePluginExecutionState(job?.pluginExecution); const expectedStage = inferExpectedPluginStage(options); - const candidates = [liveExecution, makemkvExecution, handbrakeExecution, jobExecution].filter(Boolean); + const candidates = [liveExecution, handbrakeExecution, mediainfoExecution, makemkvExecution, jobExecution].filter(Boolean); if (candidates.length === 0) { return null; } diff --git a/package-lock.json b/package-lock.json index 18d572b..2205bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.12.0-3", + "version": "0.12.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.12.0-3", + "version": "0.12.0-4", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 77f64fb..c69b544 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.12.0-3", + "version": "0.12.0-4", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",