0.12.0-4 DVD Plugin

This commit is contained in:
2026-03-21 19:12:58 +00:00
parent e99cdf1895
commit c0350644b9
11 changed files with 446 additions and 192 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.12.0-3",
"version": "0.12.0-4",
"private": true,
"type": "commonjs",
"scripts": {
+6 -6
View File
@@ -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 ─────────────────────────────────────────────────────────────
{
+53 -16
View File
@@ -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,
+372 -156
View File
@@ -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({