'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} 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 };