0.12.0-15 Fix DVD Review

This commit is contained in:
2026-03-24 07:05:45 +00:00
parent 3e3f3231be
commit 76b020d5c0
30 changed files with 5148 additions and 44 deletions
+515
View File
@@ -0,0 +1,515 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName } = require('../utils/files');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const ISO_EXTENSIONS = new Set(['iso']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'aac', 'opus', 'wav', 'ogg']);
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/**
* Erkennt den Medientyp anhand der Dateiendung.
* @returns {'video'|'audio'|'iso'|null}
*/
function detectMediaTypeFromPath(filePath) {
const ext = path.extname(String(filePath || '')).slice(1).toLowerCase();
if (ISO_EXTENSIONS.has(ext)) return 'iso';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
return null;
}
function isAudioExt(ext) {
return AUDIO_EXTENSIONS.has(String(ext).toLowerCase());
}
/**
* Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv).
*/
function listAudioFilesInDir(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries
.filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1)))
.map((e) => e.name)
.sort();
} catch (_err) {
return [];
}
}
/**
* ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei.
*/
function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) {
const args = ['-y', '-i', inputFile];
if (format === 'flac') {
const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5)));
args.push('-codec:a', 'flac', '-compression_level', String(level));
} else if (format === 'mp3') {
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
args.push('-codec:a', 'libmp3lame');
if (mode === 'vbr') {
args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)))));
} else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
}
} else if (format === 'aac') {
args.push('-codec:a', 'aac', '-b:a', `${Number(opts.aacBitrate ?? 256)}k`);
} else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') {
args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)))));
} else if (format === 'wav') {
args.push('-codec:a', 'pcm_s16le');
} else {
args.push('-codec:a', 'copy');
}
args.push(outputFile);
return { cmd: ffmpegCmd, args };
}
/**
* Source-Plugin für den Converter.
*
* Unterstützte Eingaben:
* - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode
* - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode
* - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg
* - ISO: MakeMKV-Rip → HandBrake-Encode
*
* Erkennung via mediaProfile === 'converter'.
*
* Erforderliche Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path)
* outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder
* outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien)
* encodePlan - aus encode_plan_json
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class ConverterPlugin extends SourcePlugin {
get id() { return 'converter'; }
get name() { return 'Converter'; }
get priority() { return 3; }
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
return profile === 'converter';
}
/**
* Analysiert die Eingabedatei/-ordner.
* - Video/ISO: HandBrake --scan für Track-Info
* - Audio: ffprobe für Metadaten
* - Audio-Ordner: Dateiliste + ffprobe auf erste Datei
*/
async analyze(inputPath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const actualInput = ctx.extra?.filePath || inputPath;
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType
|| detectMediaTypeFromPath(actualInput);
ctx.logger.info('converter:analyze:start', {
jobId: job?.id, inputPath: actualInput, converterMediaType
});
const settings = await ctx.settings.getSettingsMap();
const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI';
let analysisResult = { converterMediaType, inputPath: actualInput };
if (converterMediaType === 'video' || converterMediaType === 'iso') {
// HandBrake --scan für Track-Informationen
try {
const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput);
const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id });
const hbInfo = this._parseHandBrakeScan(captured.stdout);
analysisResult.handbrakeInfo = hbInfo;
ctx.logger.info('converter:analyze:handbrake-scan', {
jobId: job?.id,
titleCount: hbInfo?.TitleList?.length || 0
});
} catch (scanError) {
ctx.logger.warn('converter:analyze:handbrake-scan-failed', {
jobId: job?.id, error: scanError?.message
});
}
}
if (converterMediaType === 'audio') {
const stat = fs.statSync(actualInput);
if (stat.isDirectory()) {
// Ordner mit Audio-Dateien
const audioFiles = listAudioFilesInDir(actualInput);
analysisResult.isFolder = true;
analysisResult.audioFiles = audioFiles;
// Erster Track: ffprobe für Metadaten
if (audioFiles.length > 0) {
const firstFile = path.join(actualInput, audioFiles[0]);
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id });
analysisResult.folderMeta = meta;
} catch (_err) {
// Metadaten optional
}
}
ctx.logger.info('converter:analyze:audio-folder', {
jobId: job?.id, fileCount: audioFiles.length
});
} else {
// Einzelne Audio-Datei
analysisResult.isFolder = false;
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id });
analysisResult.fileMeta = meta;
} catch (_err) {
// Metadaten optional
}
ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id });
}
}
return analysisResult;
}
/**
* Rip-Schritt:
* - Video/Audio-Dateien: No-Op (Datei liegt bereits vor)
* - ISO: MakeMKV backup → raw_path
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType;
if (converterMediaType !== 'iso') {
ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType });
return;
}
// ISO: MakeMKV backup
const {
inputPath,
rawJobDir,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt');
}
if (!rawJobDir) {
throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt');
}
ensureDir(rawJobDir);
const settings = await ctx.settings.getSettingsMap();
const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon';
const registrationKey = String(settings?.makemkv_registration_key || '').trim() || null;
if (registrationKey) {
try {
await this._runCaptured(makemkvCmd, ['reg', registrationKey], { jobId: job?.id });
} catch (_err) {
// Registrierung ist optional
}
}
const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir];
ctx.logger.info('converter:rip:iso:start', {
jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd
});
ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …');
const runInfo = await _spawnAndWait({
cmd: makemkvCmd, args,
jobId: job?.id,
progressParser: parseMakeMkvProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' }
});
ctx.logger.info('converter:rip:iso:done', {
jobId: job?.id, exitCode: runInfo?.exitCode ?? null
});
ctx.extra.ripRunInfo = runInfo;
}
/**
* Encode-Schritt:
* - Video: HandBrakeCLI
* - Audio Einzeldatei: ffmpeg
* - Audio Ordner: ffmpeg je Datei
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const {
inputPath,
outputPath,
outputDir,
encodePlan = {},
isCancelled,
onProcessHandle
} = ctx.extra || {};
const converterMediaType = encodePlan.converterMediaType;
if (!inputPath) {
throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt');
}
const settings = await ctx.settings.getSettingsMap();
if (converterMediaType === 'video' || converterMediaType === 'iso') {
await this._encodeVideo({
job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle
});
} else if (converterMediaType === 'audio') {
await this._encodeAudio({
job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle
});
} else {
throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`);
}
}
// ── Video-Encode (HandBrake) ─────────────────────────────────────────────
async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) {
if (!outputPath) {
throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
trackSelection: encodePlan.trackSelection || null,
titleId: encodePlan.handBrakeTitleId || null,
mediaProfile: null,
settingsMap: settings,
userPreset: encodePlan.userPreset || null
});
ctx.logger.info('converter:encode:video:start', {
jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null
});
ctx.emitProgress(0, 'Converter: Video 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 || `Encoding ${pct}%`),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:video:done', {
jobId: job?.id, outputPath, exitCode: runInfo.exitCode
});
ctx.extra.encodeRunInfo = runInfo;
}
// ── Audio-Encode (ffmpeg) ────────────────────────────────────────────────
async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) {
const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
const formatOptions = encodePlan.audioFormatOptions || {};
const isFolder = Boolean(encodePlan.isFolder);
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
}
if (isFolder) {
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
if (!outputDir) {
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job');
}
ensureDir(outputDir);
ctx.logger.info('converter:encode:audio:folder:start', {
jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length
});
for (let i = 0; i < audioFiles.length; i++) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const fileName = audioFiles[i];
const inputFile = path.join(inputPath, fileName);
const baseName = path.basename(fileName, path.extname(fileName));
const outputFile = path.join(outputDir, `${sanitizeFileName(baseName)}.${outputFormat}`);
ctx.logger.info(`converter:encode:audio:track:${i + 1}`, { inputFile, outputFile });
ctx.emitProgress(
Math.round((i / audioFiles.length) * 100),
`Audio: ${i + 1}/${audioFiles.length}${fileName}`
);
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, outputFormat, formatOptions);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
}
ctx.logger.info('converter:encode:audio:folder:done', {
jobId: job?.id, fileCount: audioFiles.length, outputDir
});
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length };
} else {
// Einzelne Datei
if (!outputPath) {
throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
ctx.logger.info('converter:encode:audio:file:start', {
jobId: job?.id, inputPath, outputPath, format: outputFormat
});
ctx.emitProgress(0, `Audio: Encoding läuft …`);
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputPath, outputPath, outputFormat, formatOptions);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:audio:file:done', {
jobId: job?.id, outputPath
});
ctx.extra.encodeResult = { outputPath, format: outputFormat };
}
}
// ── Hilfsmethoden ───────────────────────────────────────────────────────
async _runCaptured(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process');
let stdout = '';
let stderr = '';
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
child.on('error', reject);
child.on('close', (code) => resolve({ exitCode: code, stdout, stderr }));
});
}
_parseHandBrakeScan(rawOutput) {
try {
const jsonStart = rawOutput.indexOf('{');
if (jsonStart < 0) return null;
return JSON.parse(rawOutput.slice(jsonStart));
} catch (_err) {
return null;
}
}
async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) {
const args = [
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath
];
const result = await this._runCaptured(ffprobeCmd, args, options);
try {
return JSON.parse(result.stdout);
} catch (_err) {
return null;
}
}
getSettingsSchema() {
return [];
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.detail || null);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
const result = await handle.promise;
return result;
} 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 = { ConverterPlugin };