0.12.0 Begin neu Architecture
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
'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 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,
|
||||
exitCode: runInfo.exitCode,
|
||||
lineCount: scanLines.length
|
||||
});
|
||||
|
||||
// Return raw scan data — the Orchestrator (or legacy pipelineService)
|
||||
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
|
||||
return {
|
||||
scanLines,
|
||||
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,
|
||||
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
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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 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, context }) {
|
||||
const lines = [];
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStdoutLine === 'function') {
|
||||
onStdoutLine(line);
|
||||
}
|
||||
},
|
||||
onStderrLine: () => {},
|
||||
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 };
|
||||
Reference in New Issue
Block a user