538 lines
17 KiB
JavaScript
538 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('path');
|
|
const { SourcePlugin } = require('./PluginBase');
|
|
const omdbService = require('../services/omdbService');
|
|
const { spawnTrackedProcess } = require('../services/processRunner');
|
|
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
|
const { ensureDir } = require('../utils/files');
|
|
|
|
/**
|
|
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
|
|
*
|
|
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
|
|
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten
|
|
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
|
|
* rip() → makemkvcon backup/mkv
|
|
* encode() → HandBrakeCLI
|
|
*
|
|
* Subklassen müssen implementieren:
|
|
* get id() → 'bluray' | 'dvd'
|
|
* get name() → Anzeigename
|
|
* get mediaProfile() → 'bluray' | 'dvd'
|
|
* detect(discInfo) → boolean
|
|
*
|
|
* ctx.extra-Felder für rip():
|
|
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
|
|
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
|
|
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
|
|
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
|
|
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
|
|
* isCancelled - () => boolean
|
|
* onProcessHandle - (handle) => void [optional]
|
|
*
|
|
* ctx.extra-Felder für encode():
|
|
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
|
|
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
|
|
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
|
|
* isCancelled - () => boolean
|
|
* onProcessHandle - (handle) => void [optional]
|
|
*
|
|
* ctx.extra-Felder für review():
|
|
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
|
|
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
|
|
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
|
|
*/
|
|
class VideoDiscPlugin extends SourcePlugin {
|
|
/**
|
|
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
|
|
* Muss von Subklassen implementiert werden.
|
|
* @returns {'bluray'|'dvd'}
|
|
*/
|
|
get mediaProfile() {
|
|
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
|
|
}
|
|
|
|
/**
|
|
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
|
|
* Gibt { detectedTitle, omdbCandidates } zurück.
|
|
*
|
|
* @param {string} devicePath
|
|
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
|
|
* @param {PluginContext} ctx
|
|
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
|
|
*/
|
|
async analyze(devicePath, job, ctx) {
|
|
ctx.markExecution('analyze', {
|
|
pluginId: this.id,
|
|
pluginName: this.name,
|
|
pluginFile: 'VideoDiscPlugin.js'
|
|
});
|
|
|
|
const discInfo = ctx.extra?.discInfo || {};
|
|
const detectedTitle = String(
|
|
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
|
|
).trim();
|
|
|
|
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
|
|
|
|
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
|
|
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
|
|
jobId: job?.id,
|
|
error: error?.message || String(error)
|
|
});
|
|
return [];
|
|
});
|
|
|
|
ctx.logger.info(`${this.id}:analyze:done`, {
|
|
jobId: job?.id,
|
|
detectedTitle,
|
|
omdbCandidates: omdbCandidates.length
|
|
});
|
|
|
|
return { detectedTitle, omdbCandidates };
|
|
}
|
|
|
|
/**
|
|
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
|
|
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
|
|
*
|
|
* ctx.extra.reviewMode:
|
|
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
|
|
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
|
|
*
|
|
* @param {object} job
|
|
* @param {PluginContext} ctx
|
|
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
|
|
*/
|
|
async review(job, ctx) {
|
|
ctx.markExecution('review', {
|
|
pluginId: this.id,
|
|
pluginName: this.name,
|
|
pluginFile: 'VideoDiscPlugin.js'
|
|
});
|
|
|
|
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
|
|
const deviceInfo = ctx.extra?.deviceInfo || null;
|
|
const rawJobDir = ctx.extra?.rawJobDir || null;
|
|
|
|
ctx.logger.info(`${this.id}:review:start`, {
|
|
jobId: job?.id,
|
|
reviewMode,
|
|
deviceInfo: deviceInfo?.path || null,
|
|
rawJobDir: rawJobDir || null
|
|
});
|
|
|
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
|
|
|
let scanConfig;
|
|
if (reviewMode === 'rip' && rawJobDir) {
|
|
// Post-Rip scan: scan from the ripped RAW folder
|
|
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
|
|
mediaProfile: this.mediaProfile,
|
|
settingsMap: settings
|
|
});
|
|
} else {
|
|
// Pre-Rip scan: scan directly from disc device
|
|
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
|
|
mediaProfile: this.mediaProfile,
|
|
settingsMap: settings
|
|
});
|
|
}
|
|
|
|
ctx.logger.info(`${this.id}:review:scan-command`, {
|
|
jobId: job?.id,
|
|
cmd: scanConfig.cmd,
|
|
args: scanConfig.args
|
|
});
|
|
|
|
const scanLines = [];
|
|
const scanAnalysisLines = [];
|
|
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) {
|
|
try {
|
|
runInfo = await runCommandFn({
|
|
jobId: job?.id,
|
|
stage: reviewStage,
|
|
source: reviewSource,
|
|
cmd: scanConfig.cmd,
|
|
args: scanConfig.args,
|
|
collectLines: scanLines,
|
|
// Keep JSON parsing stable by collecting only stdout in scanLines.
|
|
// Stderr is still mirrored into scanAnalysisLines for heuristics.
|
|
collectStderrLines: false,
|
|
onStdoutLine: (line) => scanAnalysisLines.push(line),
|
|
onStderrLine: (line) => scanAnalysisLines.push(line),
|
|
silent: reviewSilent
|
|
});
|
|
} catch (cmdError) {
|
|
// runCommand collected stdout lines via callbacks before throwing.
|
|
// Recover the runInfo from the error so the caller can still parse
|
|
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
|
|
runInfo = cmdError?.runInfo || {
|
|
status: 'ERROR',
|
|
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
|
|
errorMessage: cmdError?.message || String(cmdError)
|
|
};
|
|
}
|
|
} else {
|
|
runInfo = await _runCommandCaptured({
|
|
cmd: scanConfig.cmd,
|
|
args: scanConfig.args,
|
|
onStdoutLine: (line) => {
|
|
scanLines.push(line);
|
|
scanAnalysisLines.push(line);
|
|
},
|
|
onStderrLine: (line) => scanAnalysisLines.push(line),
|
|
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
|
});
|
|
}
|
|
|
|
ctx.logger.info(`${this.id}:review:scan-done`, {
|
|
jobId: job?.id,
|
|
exitCode: runInfo.exitCode,
|
|
lineCount: scanAnalysisLines.length
|
|
});
|
|
|
|
// Return raw scan data — the Orchestrator (or legacy pipelineService)
|
|
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
|
|
return {
|
|
scanLines,
|
|
scanAnalysisLines,
|
|
runInfo,
|
|
reviewMode,
|
|
mediaProfile: this.mediaProfile,
|
|
sourceArg: scanConfig.sourceArg || null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rippt die Disc mit makemkvcon in den rawJobDir.
|
|
*
|
|
* @param {object} job
|
|
* @param {PluginContext} ctx
|
|
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
|
|
*/
|
|
async rip(job, ctx) {
|
|
ctx.markExecution('rip', {
|
|
pluginId: this.id,
|
|
pluginName: this.name,
|
|
pluginFile: 'VideoDiscPlugin.js'
|
|
});
|
|
|
|
const {
|
|
rawJobDir,
|
|
deviceInfo,
|
|
selectedTitleId = null,
|
|
backupOutputBase = null,
|
|
disableMinLengthFilter = false,
|
|
isCancelled,
|
|
onProcessHandle
|
|
} = ctx.extra || {};
|
|
|
|
if (!rawJobDir) {
|
|
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
|
|
}
|
|
if (!deviceInfo) {
|
|
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
|
|
}
|
|
|
|
ensureDir(rawJobDir);
|
|
|
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
|
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
|
|
selectedTitleId,
|
|
mediaProfile: this.mediaProfile,
|
|
settingsMap: settings,
|
|
backupOutputBase,
|
|
disableMinLengthFilter
|
|
});
|
|
|
|
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
|
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
|
|
|
|
ctx.logger.info(`${this.id}:rip:start`, {
|
|
jobId: job?.id,
|
|
cmd: ripConfig.cmd,
|
|
args: ripConfig.args,
|
|
ripMode,
|
|
rawJobDir,
|
|
selectedTitleId,
|
|
disableMinLengthFilter
|
|
});
|
|
|
|
ctx.emitProgress(0, ripMode === 'backup'
|
|
? `${this.name}: Backup läuft …`
|
|
: `${this.name}: Ripping läuft …`
|
|
);
|
|
|
|
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
|
|
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
|
|
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
|
|
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
|
let runInfo;
|
|
if (runCommandFn) {
|
|
runInfo = await runCommandFn({
|
|
jobId: job?.id,
|
|
stage: 'RIPPING',
|
|
source: 'MAKEMKV_RIP',
|
|
cmd: ripConfig.cmd,
|
|
args: ripConfig.args,
|
|
parser: parseMakeMkvProgress
|
|
});
|
|
} else {
|
|
runInfo = await _spawnAndWait({
|
|
cmd: ripConfig.cmd,
|
|
args: ripConfig.args,
|
|
cwd: rawJobDir,
|
|
jobId: job?.id,
|
|
progressParser: parseMakeMkvProgress,
|
|
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
|
isCancelled,
|
|
onProcessHandle,
|
|
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
|
|
});
|
|
}
|
|
|
|
ctx.logger.info(`${this.id}:rip:done`, {
|
|
jobId: job?.id,
|
|
rawJobDir,
|
|
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
|
|
});
|
|
|
|
ctx.extra.ripRunInfo = runInfo;
|
|
return runInfo;
|
|
}
|
|
|
|
/**
|
|
* Encodiert die gerippten Dateien mit HandBrakeCLI.
|
|
*
|
|
* @param {object} job
|
|
* @param {PluginContext} ctx
|
|
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
|
|
*/
|
|
async encode(job, ctx) {
|
|
ctx.markExecution('encode', {
|
|
pluginId: this.id,
|
|
pluginName: this.name,
|
|
pluginFile: 'VideoDiscPlugin.js'
|
|
});
|
|
|
|
const {
|
|
inputPath,
|
|
outputPath,
|
|
encodePlan = {},
|
|
isCancelled,
|
|
onProcessHandle
|
|
} = ctx.extra || {};
|
|
|
|
if (!inputPath) {
|
|
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
|
|
}
|
|
if (!outputPath) {
|
|
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
|
|
}
|
|
|
|
ensureDir(path.dirname(outputPath));
|
|
|
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
|
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
|
trackSelection: encodePlan.trackSelection || null,
|
|
titleId: encodePlan.handBrakeTitleId || null,
|
|
mediaProfile: this.mediaProfile,
|
|
settingsMap: settings,
|
|
userPreset: encodePlan.userPreset || null
|
|
});
|
|
|
|
ctx.logger.info(`${this.id}:encode:start`, {
|
|
jobId: job?.id,
|
|
cmd: handBrakeConfig.cmd,
|
|
args: handBrakeConfig.args,
|
|
titleId: encodePlan.handBrakeTitleId || null
|
|
});
|
|
|
|
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
|
|
|
|
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,
|
|
outputPath,
|
|
exitCode: runInfo.exitCode
|
|
});
|
|
|
|
ctx.extra.encodeRunInfo = runInfo;
|
|
return runInfo;
|
|
}
|
|
|
|
/**
|
|
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
|
|
*/
|
|
getSettingsSchema() {
|
|
const profile = this.mediaProfile;
|
|
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
|
|
const orderBase = profile === 'bluray' ? 200 : 220;
|
|
|
|
return [
|
|
{
|
|
key: `handbrake_preset_${profile}`,
|
|
category: 'Tools',
|
|
label: `HandBrake Preset (${label})`,
|
|
type: 'string',
|
|
required: 0,
|
|
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
|
|
default_value: null,
|
|
options_json: '[]',
|
|
validation_json: '{}',
|
|
order_index: orderBase
|
|
},
|
|
{
|
|
key: `output_extension_${profile}`,
|
|
category: 'Tools',
|
|
label: `Ausgabe-Format (${label})`,
|
|
type: 'select',
|
|
required: 1,
|
|
description: `Dateiendung des encodierten ${label}-Outputs.`,
|
|
default_value: 'mkv',
|
|
options_json: '["mkv","mp4"]',
|
|
validation_json: '{}',
|
|
order_index: orderBase + 1
|
|
},
|
|
{
|
|
key: `output_template_${profile}`,
|
|
category: 'Pfade',
|
|
label: `Output Template (${label})`,
|
|
type: 'string',
|
|
required: 1,
|
|
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
|
|
default_value: '{title} ({year})/{title} ({year})',
|
|
options_json: '[]',
|
|
validation_json: '{"minLength":1}',
|
|
order_index: 700 + (profile === 'bluray' ? 0 : 5)
|
|
}
|
|
];
|
|
}
|
|
}
|
|
|
|
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
|
|
|
function _assertNotCancelled(isCancelled) {
|
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
|
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
|
|
const lines = [];
|
|
const handle = spawnTrackedProcess({
|
|
cmd,
|
|
args,
|
|
onStdoutLine: (line) => {
|
|
lines.push(line);
|
|
if (typeof onStdoutLine === 'function') {
|
|
onStdoutLine(line);
|
|
}
|
|
},
|
|
onStderrLine: (line) => {
|
|
lines.push(line);
|
|
if (typeof onStderrLine === 'function') {
|
|
onStderrLine(line);
|
|
}
|
|
},
|
|
context: context || {}
|
|
});
|
|
|
|
try {
|
|
await handle.promise;
|
|
return { exitCode: 0, lines };
|
|
} catch (error) {
|
|
return {
|
|
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
|
lines,
|
|
error: error?.message || String(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
|
_assertNotCancelled(isCancelled);
|
|
|
|
const handle = spawnTrackedProcess({
|
|
cmd,
|
|
args,
|
|
cwd,
|
|
onStdoutLine: (line) => {
|
|
if (progressParser && typeof onProgress === 'function') {
|
|
const progress = progressParser(line);
|
|
if (progress?.percent != null) {
|
|
onProgress(progress.percent, progress.statusText || null);
|
|
}
|
|
}
|
|
},
|
|
onStderrLine: (line) => {
|
|
if (progressParser && typeof onProgress === 'function') {
|
|
const progress = progressParser(line);
|
|
if (progress?.percent != null) {
|
|
onProgress(progress.percent, progress.statusText || null);
|
|
}
|
|
}
|
|
},
|
|
context: context || { jobId }
|
|
});
|
|
|
|
if (typeof onProcessHandle === 'function') {
|
|
onProcessHandle(handle);
|
|
}
|
|
|
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
|
handle.cancel();
|
|
}
|
|
|
|
try {
|
|
return await handle.promise;
|
|
} catch (error) {
|
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
|
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
|
cancelError.statusCode = 409;
|
|
throw cancelError;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
module.exports = { VideoDiscPlugin };
|