0.12.0 Begin neu Architecture

This commit is contained in:
2026-03-18 15:35:10 +00:00
parent d00191324b
commit 0a9cf6969f
30 changed files with 4570 additions and 183 deletions
+448
View File
@@ -0,0 +1,448 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { SourcePlugin } = require('./PluginBase');
const audiobookService = require('../services/audiobookService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { ensureDir } = require('../utils/files');
/**
* Source-Plugin für Audiobooks (AAX-Dateien).
*
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
* - Kein Rip-Schritt — die AAX-Datei ist der Input
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
*
* Deshalb:
* rip() → No-Op (Datei liegt bereits im rawDir)
* encode() → ffmpeg-Encoding (Single-File oder per Chapter)
* review() → null (kein HandBrake-Preview)
*
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
* Datei-Uploads) oder eine Dateiendung im discInfo.
*
* Pflicht-Felder in ctx.extra für analyze():
* filePath - Absoluter Pfad zur .aax-Datei
*
* Pflicht-Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur .aax-Input-Datei
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
* outputFormat - 'm4b' | 'mp3' | 'flac'
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class AudiobookPlugin extends SourcePlugin {
get id() {
return 'audiobook';
}
get name() {
return 'Audiobook (AAX)';
}
get priority() {
return 5;
}
/**
* Erkennt Audiobooks.
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'audiobook' || profile === 'audio_book') {
return true;
}
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
}
/**
* Analysiert eine .aax-Datei mit ffprobe.
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
*
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
*/
async analyze(filePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const inputPath = ctx.extra?.filePath || filePath;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const settings = await ctx.settings.getSettingsMap();
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const originalName = path.basename(inputPath);
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
const probe = audiobookService.parseProbeOutput(captured.stdout);
if (!probe) {
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
error.statusCode = 500;
throw error;
}
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
ctx.logger.info('audiobook:analyze:done', {
jobId: job?.id,
title: metadata.title,
author: metadata.author,
chapterCount: metadata.chapters?.length || 0,
durationMs: metadata.durationMs
});
return metadata;
}
/**
* No-Op: Audiobooks haben keinen Rip-Schritt.
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
*/
async rip(_job, _ctx) {
_ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
// Für Audiobooks gibt es keinen separaten Rip-Schritt
}
/**
* Encodiert die .aax-Datei mit ffmpeg.
* Unterstützt Single-File (M4B) und kapitelweise Ausgabe (MP3, FLAC).
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const {
inputPath,
outputPath,
outputFormat = 'mp3',
formatOptions = {},
metadata = {},
activationBytes = null,
isSplitOutput = false,
chapterPlan = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
}
if (!outputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
}
if (!fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const settings = await ctx.settings.getSettingsMap();
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
ctx.logger.info('audiobook:encode:start', {
jobId: job?.id,
format: normalizedFormat,
isSplitOutput,
inputPath
});
if (isSplitOutput) {
await this._encodeChapters({
job, ctx, ffmpegCommand, inputPath, chapterPlan,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
} else {
await this._encodeSingleFile({
job, ctx, ffmpegCommand, inputPath, outputPath,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
}
ctx.logger.info('audiobook:encode:done', {
jobId: job?.id,
format: normalizedFormat,
outputPath
});
// Ergebnis für den Orchestrator ablegen
ctx.extra.encodeResult = {
outputPath,
format: normalizedFormat,
chapterCount: isSplitOutput
? (chapterPlan?.outputFiles?.length || 0)
: 1
};
return ctx.extra.encodeResult;
}
/**
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audiobooks.
*/
getSettingsSchema() {
return [
{
key: 'ffmpeg_command',
category: 'Tools',
label: 'FFmpeg Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
default_value: 'ffmpeg',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 232
},
{
key: 'ffprobe_command',
category: 'Tools',
label: 'FFprobe Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
default_value: 'ffprobe',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 233
},
{
key: 'output_template_audiobook',
category: 'Pfade',
label: 'Output Template (Audiobook)',
type: 'string',
required: 1,
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 735
}
];
}
// ── Private Encode-Methoden ──────────────────────────────────────────────────
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
ensureDir(path.dirname(outputPath));
const ffmpegConfig = audiobookService.buildEncodeCommand(
ffmpegCommand,
inputPath,
outputPath,
normalizedFormat,
normalizedOptions,
{
activationBytes,
metadata
}
);
ctx.logger.info('audiobook:encode:single-file', {
jobId: job?.id,
cmd: ffmpegConfig.cmd,
outputPath
});
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser,
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin' }
});
}
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
if (outputFiles.length === 0) {
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
}
for (let index = 0; index < outputFiles.length; index++) {
_assertNotCancelled(isCancelled);
const entry = outputFiles[index];
const chapter = entry?.chapter || {};
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
const startPct = (index / outputFiles.length) * 100;
const endPct = ((index + 1) / outputFiles.length) * 100;
ensureDir(path.dirname(entry.outputPath));
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
ffmpegCommand,
inputPath,
entry.outputPath,
normalizedFormat,
normalizedOptions,
metadata,
chapter,
outputFiles.length,
{ activationBytes }
);
ctx.logger.info('audiobook:encode:chapter', {
jobId: job?.id,
chapterIndex: index + 1,
chapterTotal: outputFiles.length,
chapterTitle,
outputPath: entry.outputPath
});
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
const scaledParser = baseParser
? (line) => {
const progress = baseParser(line);
if (!progress || progress.percent == null) {
return null;
}
return {
percent: startPct + (endPct - startPct) * (progress.percent / 100),
eta: null
};
}
: null;
ctx.emitProgress(
Math.round(startPct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser: scaledParser,
onProgress: (pct) => ctx.emitProgress(
Math.round(pct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
});
}
}
}
// ── 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 _runCaptured(cmd, args, _context = {}) {
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
try {
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
} catch (error) {
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
return {
stdout: String(error?.stdout || ''),
stderr: String(error?.stderr || ''),
exitCode: error?.code ?? 1
};
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
_assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent);
}
}
},
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 = { AudiobookPlugin };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für Blu-ray Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'bluray'
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
*/
class BluRayPlugin extends VideoDiscPlugin {
get id() {
return 'bluray';
}
get name() {
return 'Blu-ray';
}
get mediaProfile() {
return 'bluray';
}
/**
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
* theoretisch auf UDF-Discs matchen könnten.
*/
get priority() {
return 10;
}
/**
* Erkennt Blu-ray Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'bluray'
* @param {string} [discInfo.fstype] - 'udf'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
return true;
}
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
// vertrauen und nicht via Modell-Marker überschreiben.
if (profile) {
return false;
}
// Fallback: nur wenn kein mediaProfile bekannt ist.
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
return true;
}
return false;
}
}
module.exports = { BluRayPlugin };
+332
View File
@@ -0,0 +1,332 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const cdRipService = require('../services/cdRipService');
/**
* Source-Plugin für Audio-CDs.
*
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
*
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
* verzahnt (rip track → encode track → nächster Track).
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
*
* Erwartete ctx.extra-Felder für rip():
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
* selectedTracks, tracks, metadata, ...)
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
* outputTemplate - CD-Output-Template String
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
*/
class CdPlugin extends SourcePlugin {
get id() {
return 'cd';
}
get name() {
return 'Audio CD';
}
get priority() {
return 10;
}
/**
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
*
* Akzeptierte Werte: 'cd', 'audio_cd'
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
}
/**
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
*
* @param {string} devicePath - z.B. '/dev/sr0'
* @param {object} job - Job-Record (id wird für Logging genutzt)
* @param {PluginContext} ctx
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const detectedTitle = String(
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
).trim();
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
error.statusCode = 400;
throw error;
}
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
return {
tracks,
cdparanoiaCmd,
detectedTitle
};
}
/**
* Rippt und encodiert alle ausgewählten Tracks der CD.
*
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
*
* Pflicht-Felder in ctx.extra:
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata }
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
* outputDir - Finaler Ausgabeordner
* outputTemplate - Template-String für Dateinamen
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const {
ripConfig = {},
rawWavDir,
outputDir,
outputTemplate,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!rawWavDir) {
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
}
if (!outputDir) {
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
}
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim();
if (!devicePath) {
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
}
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
const formatOptions = ripConfig.formatOptions || {};
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
const incomingByPos = new Map(
incomingTracks
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
.map((t) => [Math.trunc(Number(t.position)), t])
);
const mergedTracks = tocTracks
.map((track) => {
const pos = Math.trunc(Number(track?.position));
if (!Number.isFinite(pos) || pos <= 0) {
return null;
}
const incoming = incomingByPos.get(pos) || null;
return {
...track,
position: pos,
title: incoming?.title || track.title || `Track ${pos}`,
artist: incoming?.artist || track.artist || null,
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
};
})
.filter(Boolean);
const selectedMeta = cdInfo.selectedMetadata || {};
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
? ripConfig.metadata
: {};
const meta = {
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
};
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
? ripConfig.selectedTracks
.map((v) => Math.trunc(Number(v)))
.filter((v) => Number.isFinite(v) && v > 0)
: [];
const selectedTrackPositions = rawSelectedPositions.length > 0
? rawSelectedPositions
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
ctx.logger.info('cd:rip:start', {
jobId: job?.id,
devicePath,
format,
trackCount: selectedTrackPositions.length
});
const result = await cdRipService.ripAndEncode({
jobId: job?.id,
devicePath,
cdparanoiaCmd,
rawWavDir,
outputDir,
format,
formatOptions,
selectedTracks: selectedTrackPositions,
tracks: mergedTracks,
meta,
outputTemplate: effectiveTemplate,
onProgress: (progressEvent) => {
const pct = Number(progressEvent?.percent ?? 0);
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
const trackIdx = progressEvent?.trackIndex || 1;
const trackTotal = progressEvent?.trackTotal || 1;
ctx.emitProgress(
Math.round(pct),
`${phase} Track ${trackIdx}/${trackTotal}`
);
},
onLog: (level, msg) => {
if (ctx.logger[level]) {
ctx.logger[level](msg, { jobId: job?.id });
}
},
onProcessHandle,
isCancelled,
context: { jobId: job?.id, source: 'CdPlugin' }
});
ctx.logger.info('cd:rip:done', {
jobId: job?.id,
format,
trackCount: result.trackCount,
outputDir: result.outputDir
});
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
ctx.extra.ripResult = result;
return result;
}
/**
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
*/
async encode(_job, _ctx) {
_ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
}
/**
* CDs brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audio CDs.
*/
getSettingsSchema() {
return [
{
key: 'cdparanoia_command',
category: 'Tools',
label: 'cdparanoia Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
default_value: 'cdparanoia',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 240
},
{
key: 'cd_output_template',
category: 'Pfade',
label: 'CD Output Template',
type: 'string',
required: 1,
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 730
}
];
}
}
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
function _safeParseJson(value) {
try {
return value ? JSON.parse(value) : null;
} catch {
return null;
}
}
function _normText(value) {
const s = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return s || null;
}
function _normYear(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
module.exports = { CdPlugin };
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für DVD-Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'dvd'
* - Dateisystem: UDF (1.02) oder ISO9660
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
*/
class DVDPlugin extends VideoDiscPlugin {
get id() {
return 'dvd';
}
get name() {
return 'DVD';
}
get mediaProfile() {
return 'dvd';
}
/**
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
*/
get priority() {
return 5;
}
/**
* Erkennt DVD-Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'dvd'
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'dvd') {
return true;
}
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
if (hasBlurayMarker) {
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
}
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
return true;
}
return false;
}
}
module.exports = { DVDPlugin };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/**
* Abstrakte Basisklasse für Source-Plugins.
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
*
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
*
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
*/
class SourcePlugin {
/**
* Eindeutiger Bezeichner des Plugins.
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
* @returns {string}
*/
get id() {
throw new Error(`${this.constructor.name}: id nicht implementiert`);
}
/**
* Anzeigename des Plugins (für Logs und UI).
* @returns {string}
*/
get name() {
throw new Error(`${this.constructor.name}: name nicht implementiert`);
}
/**
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
* Standard: 0
* @returns {number}
*/
get priority() {
return 0;
}
/**
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
* Wird vom PluginRegistry.findPlugin() aufgerufen.
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @param {string} [discInfo.devicePath]
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
}
/**
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
*
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
* @param {object} job - Bestehender Job-Record aus der DB
* @param {PluginContext} ctx
* @returns {Promise<object>} Analyse-Ergebnis
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
*/
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
}
/**
* Rippt das Medium in den RAW-Ordner.
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async rip(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
}
/**
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object|null>} Review-Daten oder null
*/
async review(job, ctx) { // eslint-disable-line no-unused-vars
return null;
}
/**
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async encode(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
}
/**
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
// Default: kein Finalize-Schritt notwendig
}
/**
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
* Wird vom Orchestrator bei cancel() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
* Wird vom Orchestrator vor retry() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Plugin-spezifische Settings-Schema-Einträge.
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
* und können zur Laufzeit in die DB eingetragen werden.
*
* Format je Eintrag:
* { key, category, label, type, required, description,
* default_value, options_json, validation_json, order_index }
*
* @returns {Array<object>}
*/
getSettingsSchema() {
return [];
}
}
module.exports = { SourcePlugin };
+194
View File
@@ -0,0 +1,194 @@
'use strict';
function normalizeExecutionStage(value) {
const stage = String(value || '').trim().toLowerCase();
return stage || 'unknown';
}
function normalizePluginExecutionState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
const pluginName = String(value.pluginName || '').trim() || pluginId;
const pluginFile = String(value.pluginFile || '').trim() || null;
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
const rawLastStage = String(value.lastStage || '').trim();
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
const jobIdRaw = Number(value.jobId);
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
? value.byStage
: {};
const byStage = {};
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
const normalizedStage = normalizeExecutionStage(stageKey);
const count = Number(stageMeta?.count);
byStage[normalizedStage] = {
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
};
}
const explicitStages = Array.isArray(value.stages)
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
: [];
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage),
...(lastStage ? [lastStage] : [])
]));
return {
markerSource,
pluginId,
pluginName,
pluginFile,
jobId,
firstMarkedAt,
lastMarkedAt,
lastStage,
stages,
byStage
};
}
function mergePluginExecutionState(existingState, marker) {
const existing = normalizePluginExecutionState(existingState);
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
if (!normalizedMarker) {
return existing;
}
const stage = normalizeExecutionStage(normalizedMarker.stage);
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
const previousStageMeta = existing?.byStage?.[stage] || null;
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
const nextByStage = {
...(existing?.byStage || {}),
[stage]: {
count: nextStageCount,
lastMarkedAt: markedAt
}
};
const nextStages = Array.from(new Set([
...(Array.isArray(existing?.stages) ? existing.stages : []),
stage
]));
return {
markerSource: 'plugin-file',
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
? Math.trunc(Number(normalizedMarker.jobId))
: (existing?.jobId || null),
firstMarkedAt: existing?.firstMarkedAt || markedAt,
lastMarkedAt: markedAt,
lastStage: stage,
stages: nextStages,
byStage: nextByStage
};
}
/**
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
*
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
*/
class PluginContext {
/**
* @param {object} options
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
* @param {object} options.websocket - websocketService (broadcast() etc.)
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
* @param {Function} options.emitState - (newState: string, context?: object) => void
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
*/
constructor({
settings,
db,
logger,
websocket,
processRunner,
emitProgress,
emitState,
onPluginExecution,
extra = {}
} = {}) {
this.settings = settings;
this.db = db;
this.logger = logger;
this.websocket = websocket;
this.processRunner = processRunner;
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
this.emitState = typeof emitState === 'function' ? emitState : () => {};
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
this.extra = extra;
this.pluginExecution = null;
}
markExecution(stage, payload = {}) {
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
const marker = {
markerSource: 'plugin-file',
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(payload?.pluginName || '').trim()
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(payload?.pluginFile || '').trim() || null,
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
stage: normalizeExecutionStage(stage),
markedAt: new Date().toISOString()
};
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
try {
this.onPluginExecution(marker, this.getPluginExecution());
} catch (error) {
if (this.logger && typeof this.logger.warn === 'function') {
this.logger.warn('plugin:execution:callback-failed', {
pluginId: marker.pluginId,
pluginFile: marker.pluginFile,
stage: marker.stage,
error: error?.message || String(error)
});
}
}
return this.getPluginExecution();
}
getPluginExecution() {
const normalized = normalizePluginExecutionState(this.pluginExecution);
if (!normalized) {
return null;
}
return {
...normalized,
stages: [...normalized.stages],
byStage: Object.fromEntries(
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
stage,
{
count: Number(meta?.count || 1),
lastMarkedAt: meta?.lastMarkedAt || null
}
])
)
};
}
}
module.exports = { PluginContext };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
/**
* Registry für alle Source-Plugins.
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
*
* Verwendung:
* const { registry } = require('./PluginRegistry');
* registry.register(new BluRayPlugin());
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
*/
class PluginRegistry {
constructor() {
/** @type {Map<string, SourcePlugin>} */
this._plugins = new Map();
}
/**
* Registriert ein Plugin.
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
*
* @param {SourcePlugin} plugin
*/
register(plugin) {
if (!(plugin instanceof SourcePlugin)) {
throw new Error(
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
);
}
const id = plugin.id;
if (!id || typeof id !== 'string') {
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
}
if (this._plugins.has(id)) {
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
}
this._plugins.set(id, plugin);
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
}
/**
* Findet das passende Plugin für eine erkannte Disc.
*
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
* dessen detect() true zurückgibt, wird verwendet.
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
*/
findPlugin(discInfo) {
const sorted = [...this._plugins.values()]
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
for (const plugin of sorted) {
try {
if (plugin.detect(discInfo)) {
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
return plugin;
}
} catch (error) {
logger.warn('registry:detect-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
return null;
}
/**
* Gibt ein Plugin anhand seiner ID zurück.
*
* @param {string} id
* @returns {SourcePlugin|null}
*/
getPlugin(id) {
return this._plugins.get(id) ?? null;
}
/**
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
*
* @returns {SourcePlugin[]}
*/
getAllPlugins() {
return [...this._plugins.values()];
}
/**
* Aggregiert die Settings-Schemata aller registrierten Plugins.
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
*
* @returns {Array<object>}
*/
getSettingsSchemas() {
const schemas = [];
for (const plugin of this._plugins.values()) {
try {
const pluginSchemas = plugin.getSettingsSchema();
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
schemas.push(...pluginSchemas);
}
} catch (error) {
logger.warn('registry:schema-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
return schemas;
}
/**
* Gibt die Anzahl der registrierten Plugins zurück.
* @returns {number}
*/
get size() {
return this._plugins.size;
}
}
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
const registry = new PluginRegistry();
module.exports = { PluginRegistry, registry };
+471
View File
@@ -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 };