From 0a9cf6969f7855c385aab02b9b01f591968e5bdd Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 18 Mar 2026 15:35:10 +0000 Subject: [PATCH] 0.12.0 Begin neu Architecture --- backend/nodemon.json | 11 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 47 + backend/src/plugins/AudiobookPlugin.js | 448 ++++++ backend/src/plugins/BluRayPlugin.js | 70 + backend/src/plugins/CdPlugin.js | 332 ++++ backend/src/plugins/DVDPlugin.js | 66 + backend/src/plugins/PluginBase.js | 159 ++ backend/src/plugins/PluginContext.js | 194 +++ backend/src/plugins/PluginRegistry.js | 132 ++ backend/src/plugins/VideoDiscPlugin.js | 471 ++++++ backend/src/services/cdRipService.js | 13 +- backend/src/services/diskDetectionService.js | 13 +- backend/src/services/downloadService.js | 45 +- backend/src/services/historyService.js | 1464 +++++++++++++++++- backend/src/services/pipelineService.js | 669 +++++++- backend/src/services/websocketService.js | 82 +- db/schema.sql | 20 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 17 +- frontend/src/components/JobDetailDialog.jsx | 155 +- frontend/src/pages/DashboardPage.jsx | 42 +- frontend/src/pages/DatabasePage.jsx | 33 +- frontend/src/pages/HistoryPage.jsx | 135 ++ frontend/src/styles/app.css | 40 +- frontend/src/utils/pluginExecution.js | 77 + package-lock.json | 4 +- package.json | 2 +- 30 files changed, 4570 insertions(+), 183 deletions(-) create mode 100644 backend/nodemon.json create mode 100644 backend/src/plugins/AudiobookPlugin.js create mode 100644 backend/src/plugins/BluRayPlugin.js create mode 100644 backend/src/plugins/CdPlugin.js create mode 100644 backend/src/plugins/DVDPlugin.js create mode 100644 backend/src/plugins/PluginBase.js create mode 100644 backend/src/plugins/PluginContext.js create mode 100644 backend/src/plugins/PluginRegistry.js create mode 100644 backend/src/plugins/VideoDiscPlugin.js create mode 100644 frontend/src/utils/pluginExecution.js diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..8a71b14 --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,11 @@ +{ + "watch": [ + "src", + ".env" + ], + "ext": "js,json,env", + "ignore": [ + "data/**", + "logs/**" + ] +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 842ca5b..889cc9a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.11.0-5", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.11.0-5", + "version": "0.12.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index f53c280..be26a68 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.11.0-5", + "version": "0.12.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 3489651..875d6eb 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -974,6 +974,53 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`); + // Plugin-Architektur Toggle (Phase 2) + const pluginArchitectureSettings = [ + { + key: 'use_plugin_architecture', + label: 'Neue Plugin-Architektur (Beta)', + description: 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.', + defaultValue: 'false', + orderIndex: 9999 + }, + { + key: 'use_plugin_architecture_bluray', + label: 'Beta: Blu-ray Plugin aktiv', + description: 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + defaultValue: 'true', + orderIndex: 10000 + }, + { + key: 'use_plugin_architecture_dvd', + label: 'Beta: DVD Plugin aktiv', + description: 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + defaultValue: 'true', + orderIndex: 10001 + }, + { + key: 'use_plugin_architecture_cd', + label: 'Beta: Audio-CD Plugin aktiv', + description: 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + defaultValue: 'true', + orderIndex: 10002 + }, + { + key: 'use_plugin_architecture_audiobook', + label: 'Beta: Audiobook Plugin aktiv', + description: 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + defaultValue: 'true', + orderIndex: 10003 + } + ]; + for (const setting of pluginArchitectureSettings) { + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES (?, 'Erweitert', ?, 'boolean', 1, ?, ?, '[]', '{}', ?)`, + [setting.key, setting.label, setting.description, setting.defaultValue, setting.orderIndex] + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]); + } + await db.run(` CREATE TABLE IF NOT EXISTS user_prefs ( key TEXT PRIMARY KEY, diff --git a/backend/src/plugins/AudiobookPlugin.js b/backend/src/plugins/AudiobookPlugin.js new file mode 100644 index 0000000..efad71b --- /dev/null +++ b/backend/src/plugins/AudiobookPlugin.js @@ -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} 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 }; diff --git a/backend/src/plugins/BluRayPlugin.js b/backend/src/plugins/BluRayPlugin.js new file mode 100644 index 0000000..eed46b6 --- /dev/null +++ b/backend/src/plugins/BluRayPlugin.js @@ -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 }; diff --git a/backend/src/plugins/CdPlugin.js b/backend/src/plugins/CdPlugin.js new file mode 100644 index 0000000..3b46ded --- /dev/null +++ b/backend/src/plugins/CdPlugin.js @@ -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 }; diff --git a/backend/src/plugins/DVDPlugin.js b/backend/src/plugins/DVDPlugin.js new file mode 100644 index 0000000..70bb51a --- /dev/null +++ b/backend/src/plugins/DVDPlugin.js @@ -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 }; diff --git a/backend/src/plugins/PluginBase.js b/backend/src/plugins/PluginBase.js new file mode 100644 index 0000000..28ef4e0 --- /dev/null +++ b/backend/src/plugins/PluginBase.js @@ -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} 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} + */ + 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} 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + getSettingsSchema() { + return []; + } +} + +module.exports = { SourcePlugin }; diff --git a/backend/src/plugins/PluginContext.js b/backend/src/plugins/PluginContext.js new file mode 100644 index 0000000..2492dfc --- /dev/null +++ b/backend/src/plugins/PluginContext.js @@ -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 }; diff --git a/backend/src/plugins/PluginRegistry.js b/backend/src/plugins/PluginRegistry.js new file mode 100644 index 0000000..081bcb2 --- /dev/null +++ b/backend/src/plugins/PluginRegistry.js @@ -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} */ + 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} + */ + 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 }; diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js new file mode 100644 index 0000000..f539d31 --- /dev/null +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -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} 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} 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 }; diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index dc8c430..fd05490 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -406,7 +406,8 @@ async function ripAndEncode(options) { onLog, onProcessHandle, isCancelled, - context + context, + skipRip = false } = options; if (!SUPPORTED_FORMATS.has(format)) { @@ -437,6 +438,15 @@ async function ripAndEncode(options) { }; // ── Phase 1: Rip each selected track to WAV ────────────────────────────── + if (skipRip) { + // Encode-only: WAV-Dateien müssen bereits vorhanden sein + for (const track of tracksToRip) { + const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + if (!fs.existsSync(wavFile)) { + throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`); + } + } + } else { for (let i = 0; i < tracksToRip.length; i++) { assertNotCancelled(isCancelled); const track = tracksToRip[i]; @@ -501,6 +511,7 @@ async function ripAndEncode(options) { log('info', `Track ${track.position} gerippt.`); } + } // end if (!skipRip) // ── Phase 2: Encode WAVs to target format ───────────────────────────────── const encodeResults = []; diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index 24df9cb..f69525a 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -759,15 +759,17 @@ class DiskDetectionService extends EventEmitter { async checkMediaPresent(devicePath) { let blkidType = null; + let blkidError = null; try { const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); blkidType = String(stdout || '').trim().toLowerCase() || null; } catch (_error) { + blkidError = String(_error?.message || _error || 'unknown'); // blkid failed – could mean no disc, or an audio CD (no filesystem type) } + logger.info('check-media:blkid', { devicePath, blkidType, blkidError }); if (blkidType) { - logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType }); return { hasMedia: true, type: blkidType }; } @@ -790,10 +792,19 @@ class DiskDetectionService extends EventEmitter { const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1'); const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1'); const hasCD = props['ID_CDROM_MEDIA_CD'] === '1'; + logger.info('check-media:udevadm', { devicePath, hasBD, hasDVD, hasCD }); if (hasCD && !hasDVD && !hasBD) { logger.debug('udevadm:audio-cd', { devicePath }); return { hasMedia: true, type: 'audio_cd' }; } + if (hasBD) { + logger.debug('udevadm:bluray', { devicePath }); + return { hasMedia: true, type: 'udf' }; + } + if (hasDVD) { + logger.debug('udevadm:dvd', { devicePath }); + return { hasMedia: true, type: 'udf' }; + } } catch (_udevError) { // udevadm not available or failed – ignore } diff --git a/backend/src/services/downloadService.js b/backend/src/services/downloadService.js index 4566b49..6f8fb70 100644 --- a/backend/src/services/downloadService.js +++ b/backend/src/services/downloadService.js @@ -119,6 +119,7 @@ class DownloadService { } const nowIso = new Date().toISOString(); + const pendingResumeIds = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.json')) { @@ -136,11 +137,34 @@ class DownloadService { let changed = false; if (item.status === 'queued' || item.status === 'processing') { - item.status = 'failed'; - item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.'; - item.finishedAt = nowIso; - changed = true; - await this._safeUnlink(item.partialPath); + const archiveExists = await this._pathExists(item.archivePath); + if (archiveExists) { + const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null); + item.status = 'ready'; + item.errorMessage = null; + item.finishedAt = item.finishedAt || nowIso; + item.sizeBytes = Number.isFinite(Number(archiveStat?.size)) + ? archiveStat.size + : item.sizeBytes; + changed = true; + logger.warn('download:init:recovered-ready-archive', { + id: item.id, + archiveName: item.archiveName + }); + } else { + item.status = 'queued'; + item.startedAt = null; + item.finishedAt = null; + item.errorMessage = null; + item.sizeBytes = null; + changed = true; + pendingResumeIds.push(item.id); + await this._safeUnlink(item.partialPath); + logger.warn('download:init:requeue-interrupted-job', { + id: item.id, + archiveName: item.archiveName + }); + } } else if (item.status === 'ready') { const exists = await this._pathExists(item.archivePath); if (!exists) { @@ -157,6 +181,17 @@ class DownloadService { await this._persistItem(item); } } + + if (pendingResumeIds.length > 0) { + logger.warn('download:init:resume-pending-jobs', { + count: pendingResumeIds.length + }); + setImmediate(() => { + for (const id of pendingResumeIds) { + void this._startArchiveJob(id); + } + }); + } } _normalizeLoadedItem(rawItem, fallbackDir) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index c9d156e..87a8775 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const settingsService = require('./settingsService'); const omdbService = require('./omdbService'); +const cdRipService = require('./cdRipService'); const { getJobLogDir } = require('./logPathService'); const thumbnailService = require('./thumbnailService'); @@ -24,6 +25,9 @@ const processLogStreams = new Map(); const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other']; const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; +const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i; +const CD_OUTPUT_AUDIO_EXTENSIONS = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg']); +const CD_RAW_TRACK_FILE_PATTERN = /^track(\d{1,3})\.cdda\.wav$/i; function inspectDirectory(dirPath) { if (!dirPath) { @@ -651,6 +655,14 @@ function stripRawFolderStatePrefix(folderName) { .trim(); } +function stripRawFolderJobSuffix(folderName) { + const rawName = String(folderName || '').trim(); + if (!rawName) { + return ''; + } + return rawName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim(); +} + function applyRawFolderPrefix(folderName, prefix = '') { const normalized = stripRawFolderStatePrefix(folderName); if (!normalized) { @@ -663,9 +675,11 @@ function applyRawFolderPrefix(folderName, prefix = '') { function parseRawFolderMetadata(folderName) { const rawName = String(folderName || '').trim(); const normalizedRawName = stripRawFolderStatePrefix(rawName); - const folderJobIdMatch = normalizedRawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); - const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null; - let working = normalizedRawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim(); + const folderJobIdMatches = Array.from(normalizedRawName.matchAll(/-\s*RAW\s*-\s*job-(\d+)/ig)); + const folderJobId = folderJobIdMatches.length > 0 + ? Number(folderJobIdMatches[folderJobIdMatches.length - 1][1]) + : null; + let working = stripRawFolderJobSuffix(normalizedRawName); const imdbMatch = working.match(/\[(tt\d{6,12})\]/i); const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null; @@ -697,25 +711,792 @@ function buildRawPathForJobId(rawPath, jobId) { const absRawPath = normalizeComparablePath(rawPath); const folderName = path.basename(absRawPath); - - // Replace existing job ID suffix if present - const replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`); - if (replaced !== folderName) { - return path.join(path.dirname(absRawPath), replaced); - } - - // No existing job ID suffix — add canonical suffix - // Strip any state prefix (Rip_Complete_ / Incomplete_), append suffix, restore prefix const statePrefix = /^Rip_Complete_/i.test(folderName) ? RAW_RIP_COMPLETE_PREFIX : /^Incomplete_/i.test(folderName) ? RAW_INCOMPLETE_PREFIX : ''; - const stripped = stripRawFolderStatePrefix(folderName); + const stripped = stripRawFolderJobSuffix(stripRawFolderStatePrefix(folderName)); + if (!stripped) { + return absRawPath; + } const withJobId = `${statePrefix}${stripped} - RAW - job-${Math.trunc(normalizedJobId)}`; return path.join(path.dirname(absRawPath), withJobId); } +function normalizeCdFormat(value) { + const raw = String(value || '').trim().toLowerCase(); + return cdRipService.SUPPORTED_FORMATS.has(raw) ? raw : null; +} + +function normalizeAudioPathToken(value) { + const raw = String(value || '').trim().replace(/^[("'`]+|[)"'`,;]+$/g, ''); + if (!raw.startsWith('/')) { + return null; + } + return raw; +} + +function extractAbsolutePathTokens(text) { + const source = String(text || ''); + const tokens = []; + const seen = new Set(); + const pushToken = (value) => { + const normalized = normalizeAudioPathToken(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + tokens.push(normalized); + }; + + let match; + const quotedPattern = /'([^']+)'|"([^"]+)"/g; + while ((match = quotedPattern.exec(source)) !== null) { + pushToken(match[1] || match[2] || ''); + } + + const sanitized = source.replace(/'[^']*'|"[^"]*"/g, ' '); + const barePattern = /(^|\s)(\/[^\s]+)/g; + while ((match = barePattern.exec(sanitized)) !== null) { + pushToken(match[2] || ''); + } + + return tokens; +} + +function parseProcessLogTimestamp(line) { + const match = String(line || '').match(/^\[([^\]]+)\]/); + if (!match) { + return null; + } + const parsed = Date.parse(match[1]); + return Number.isFinite(parsed) ? parsed : null; +} + +function readProcessLogFileLines(filePath) { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + return String(raw || '') + .split(/\r\n|\n|\r/) + .filter((line) => line.length > 0); + } catch (_error) { + return []; + } +} + +function listJobProcessLogFiles() { + const logDir = getJobLogDir(); + try { + if (!fs.existsSync(logDir) || !fs.statSync(logDir).isDirectory()) { + return []; + } + return fs.readdirSync(logDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && PROCESS_LOG_FILE_PATTERN.test(entry.name)) + .map((entry) => { + const match = entry.name.match(PROCESS_LOG_FILE_PATTERN); + const jobId = match ? Number(match[1]) : null; + return { + fileName: entry.name, + filePath: path.join(logDir, entry.name), + jobId: Number.isFinite(jobId) ? Math.trunc(jobId) : null + }; + }); + } catch (_error) { + return []; + } +} + +function pickPreferredExistingPath(candidates = []) { + const normalizedCandidates = []; + const seen = new Set(); + + for (const candidate of Array.isArray(candidates) ? candidates : []) { + const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null; + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + normalizedCandidates.push(normalized); + } + + for (let index = normalizedCandidates.length - 1; index >= 0; index -= 1) { + const candidate = normalizedCandidates[index]; + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch (_error) { + // ignore fs errors while preferring an existing path + } + } + + return normalizedCandidates[normalizedCandidates.length - 1] || null; +} + +function collectAudioFilesRecursively(rootPath, options = {}) { + const maxDepth = Number.isFinite(Number(options.maxDepth)) ? Math.max(0, Math.trunc(Number(options.maxDepth))) : 6; + const maxFiles = Number.isFinite(Number(options.maxFiles)) ? Math.max(1, Math.trunc(Number(options.maxFiles))) : 2000; + const result = []; + const normalizedRoot = String(rootPath || '').trim(); + if (!normalizedRoot || !fs.existsSync(normalizedRoot)) { + return result; + } + + const pushFile = (filePath) => { + if (result.length >= maxFiles) { + return; + } + const ext = path.extname(filePath).toLowerCase(); + if (CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + result.push(filePath); + } + }; + + try { + const stat = fs.statSync(normalizedRoot); + if (stat.isFile()) { + pushFile(normalizedRoot); + return result; + } + } catch (_error) { + return result; + } + + const queue = [{ dirPath: normalizedRoot, depth: 0 }]; + while (queue.length > 0 && result.length < maxFiles) { + const current = queue.shift(); + if (!current) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(current.dirPath, { withFileTypes: true }); + } catch (_error) { + continue; + } + + entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + for (const entry of entries) { + const absPath = path.join(current.dirPath, entry.name); + if (entry.isFile()) { + pushFile(absPath); + } else if (entry.isDirectory() && current.depth < maxDepth) { + queue.push({ dirPath: absPath, depth: current.depth + 1 }); + } + if (result.length >= maxFiles) { + break; + } + } + } + + return result; +} + +function parseCdOutputTrackFromPath(filePath) { + const ext = path.extname(String(filePath || '')).toLowerCase(); + if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + return null; + } + + const baseName = path.basename(filePath, ext).replace(/\s+/g, ' ').trim(); + if (!baseName) { + return null; + } + + const numberMatch = baseName.match(/^(\d{1,3})(.*)$/); + const parsedPosition = numberMatch ? Number(numberMatch[1]) : null; + const position = Number.isFinite(parsedPosition) && parsedPosition > 0 ? Math.trunc(parsedPosition) : null; + let remainder = numberMatch ? String(numberMatch[2] || '').replace(/^[-._\s]+/, '').trim() : baseName; + let artist = null; + let title = remainder || baseName; + + const separatorIndex = remainder.indexOf(' - '); + if (separatorIndex > 0) { + artist = remainder.slice(0, separatorIndex).trim() || null; + title = remainder.slice(separatorIndex + 3).trim() || remainder; + } + + return { + position, + artist, + title: title || baseName, + format: normalizeCdFormat(ext.replace(/^\./, '')), + filePath + }; +} + +function assignMissingTrackPositions(entries = []) { + const used = new Set( + (Array.isArray(entries) ? entries : []) + .map((entry) => Number(entry?.position)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + + let nextPosition = 1; + return (Array.isArray(entries) ? entries : []).map((entry) => { + const current = Number(entry?.position); + if (Number.isFinite(current) && current > 0) { + return { + ...entry, + position: Math.trunc(current) + }; + } + + while (used.has(nextPosition)) { + nextPosition += 1; + } + const assigned = nextPosition; + used.add(assigned); + nextPosition += 1; + return { + ...entry, + position: assigned + }; + }); +} + +function findMostCommonString(values = []) { + const counts = new Map(); + for (const value of Array.isArray(values) ? values : []) { + const normalized = String(value || '').trim(); + if (!normalized) { + continue; + } + counts.set(normalized, (counts.get(normalized) || 0) + 1); + } + + let bestValue = null; + let bestCount = 0; + for (const [value, count] of counts.entries()) { + if (count > bestCount) { + bestValue = value; + bestCount = count; + } + } + return bestValue; +} + +function normalizeComparableLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[^\x00-\x7F]+/g, '') + .replace(/[^A-Za-z0-9]+/g, ' ') + .trim() + .toLowerCase(); +} + +function parseCdFolderMetadataFromOutputDir(outputDir) { + const normalized = String(outputDir || '').trim(); + if (!normalized) { + return { + artist: null, + album: null, + year: null + }; + } + + let working = path.basename(normalized).replace(/\s+/g, ' ').trim(); + if (!working || working === '.' || working === path.sep) { + return { + artist: null, + album: null, + year: null + }; + } + + const yearMatch = working.match(/\((19|20)\d{2}\)\s*$/); + const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null; + if (yearMatch) { + working = working.slice(0, working.length - yearMatch[0].length).trim(); + } + + const separatorIndex = working.indexOf(' - '); + const artist = separatorIndex > 0 ? working.slice(0, separatorIndex).trim() || null : null; + const album = separatorIndex > 0 + ? working.slice(separatorIndex + 3).trim() || null + : (working || null); + + return { + artist, + album, + year: Number.isFinite(year) ? year : null + }; +} + +function directoryContainsAudioFiles(dirPath) { + try { + if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return false; + } + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const absPath = path.join(dirPath, entry.name); + if (entry.isFile()) { + if (CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + return true; + } + } else if (entry.isDirectory()) { + const nestedEntries = fs.readdirSync(absPath, { withFileTypes: true }); + if (nestedEntries.some((nested) => nested.isFile() && CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(nested.name).toLowerCase()))) { + return true; + } + } + } + } catch (_error) { + return false; + } + return false; +} + +function findCdOutputDirByMetadata(options = {}) { + const settings = options.settings && typeof options.settings === 'object' ? options.settings : {}; + const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object' + ? options.baseMetadata + : {}; + const outputTemplate = String(options.outputTemplate || settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings, 'cd'); + const outputBaseDir = String(effectiveSettings?.movie_dir || effectiveSettings?.raw_dir || '').trim() || null; + if (!outputBaseDir) { + return null; + } + + const metadataVariants = []; + const pushMetadataVariant = (candidate) => { + if (!candidate || typeof candidate !== 'object') { + return; + } + const title = String(candidate.title || candidate.album || '').trim() || null; + const artist = String(candidate.artist || '').trim() || null; + const yearRaw = Number(candidate.year); + const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null; + if (!title && !artist && !year) { + return; + } + const key = JSON.stringify({ title, artist, year }); + if (metadataVariants.some((entry) => entry.key === key)) { + return; + } + metadataVariants.push({ + key, + meta: { + title, + album: title, + artist, + year + } + }); + }; + + pushMetadataVariant(baseMetadata); + pushMetadataVariant({ + title: baseMetadata.album || baseMetadata.title || null, + artist: null, + year: baseMetadata.year || null + }); + + for (const variant of metadataVariants) { + try { + if (!variant.meta.title) { + continue; + } + const candidatePath = cdRipService.buildOutputDir(variant.meta, outputBaseDir, outputTemplate); + if (candidatePath && fs.existsSync(candidatePath) && directoryContainsAudioFiles(candidatePath)) { + return candidatePath; + } + } catch (_error) { + // ignore template/render errors and continue with directory scan fallback + } + } + + try { + if (!fs.existsSync(outputBaseDir) || !fs.statSync(outputBaseDir).isDirectory()) { + return null; + } + const targetAlbum = normalizeComparableLabel(baseMetadata.album || baseMetadata.title || ''); + const targetArtist = normalizeComparableLabel(baseMetadata.artist || ''); + const targetYearRaw = Number(baseMetadata.year); + const targetYear = Number.isFinite(targetYearRaw) && targetYearRaw > 0 ? Math.trunc(targetYearRaw) : null; + let best = null; + let bestScore = -1; + const entries = fs.readdirSync(outputBaseDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const candidatePath = path.join(outputBaseDir, entry.name); + if (!directoryContainsAudioFiles(candidatePath)) { + continue; + } + const parsed = parseCdFolderMetadataFromOutputDir(candidatePath); + const candidateAlbum = normalizeComparableLabel(parsed.album || ''); + const candidateArtist = normalizeComparableLabel(parsed.artist || ''); + const candidateYearRaw = Number(parsed.year); + const candidateYear = Number.isFinite(candidateYearRaw) && candidateYearRaw > 0 ? Math.trunc(candidateYearRaw) : null; + + let score = 0; + if (targetAlbum && candidateAlbum === targetAlbum) { + score += 10; + } else if (targetAlbum && candidateAlbum.includes(targetAlbum)) { + score += 6; + } else if (targetAlbum && targetAlbum.includes(candidateAlbum) && candidateAlbum) { + score += 4; + } + if (targetYear && candidateYear === targetYear) { + score += 5; + } + if (targetArtist && candidateArtist === targetArtist) { + score += 3; + } + if (score > bestScore) { + best = candidatePath; + bestScore = score; + } + } + return bestScore > 0 ? best : null; + } catch (_error) { + return null; + } +} + +function extractCommandToken(commandLine) { + const raw = String(commandLine || '').trim(); + if (!raw) { + return null; + } + const match = raw.match(/^'([^']+)'|"([^"]+)"|(\S+)/); + return String(match?.[1] || match?.[2] || match?.[3] || '').trim() || null; +} + +function findRelatedJobLogSources(options = {}) { + const relatedJobIds = Array.isArray(options.relatedJobIds) + ? options.relatedJobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + const excludeJobIds = new Set( + (Array.isArray(options.excludeJobIds) ? options.excludeJobIds : []) + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + ); + const rawPathCandidates = Array.isArray(options.rawPathCandidates) + ? options.rawPathCandidates + .map((value) => String(value || '').trim()) + .filter(Boolean) + : []; + const pathNeedles = Array.from(new Set( + rawPathCandidates + .map((value) => normalizeComparablePath(value)) + .filter(Boolean) + )); + + const collected = new Map(); + const addSource = (filePath, matchedBy) => { + const normalizedFilePath = String(filePath || '').trim(); + if (!normalizedFilePath || collected.has(normalizedFilePath)) { + return; + } + const lines = readProcessLogFileLines(normalizedFilePath); + if (lines.length === 0) { + return; + } + const fileName = path.basename(normalizedFilePath); + const match = fileName.match(PROCESS_LOG_FILE_PATTERN); + const parsedJobId = match ? Number(match[1]) : null; + const jobId = Number.isFinite(parsedJobId) ? Math.trunc(parsedJobId) : null; + if (jobId && excludeJobIds.has(jobId)) { + return; + } + const firstTimestampMs = lines.reduce((found, line) => { + if (found !== null) { + return found; + } + return parseProcessLogTimestamp(line); + }, null); + collected.set(normalizedFilePath, { + filePath: normalizedFilePath, + fileName, + jobId, + matchedBy: matchedBy ? [matchedBy] : [], + lines, + firstTimestampMs + }); + }; + + for (const relatedJobId of relatedJobIds) { + if (excludeJobIds.has(relatedJobId)) { + continue; + } + const directPath = toProcessLogPath(relatedJobId); + if (directPath && fs.existsSync(directPath)) { + addSource(directPath, `jobId:${relatedJobId}`); + } + } + + if (pathNeedles.length > 0) { + for (const entry of listJobProcessLogFiles()) { + if (!entry?.filePath || collected.has(entry.filePath)) { + continue; + } + if (entry.jobId && excludeJobIds.has(entry.jobId)) { + continue; + } + + let rawText = ''; + try { + rawText = fs.readFileSync(entry.filePath, 'utf-8'); + } catch (_error) { + continue; + } + + const matchedNeedle = pathNeedles.find((needle) => rawText.includes(needle)); + if (matchedNeedle) { + addSource(entry.filePath, `path:${matchedNeedle}`); + } + } + } + + return Array.from(collected.values()) + .sort((a, b) => { + if (a.firstTimestampMs !== null && b.firstTimestampMs !== null && a.firstTimestampMs !== b.firstTimestampMs) { + return a.firstTimestampMs - b.firstTimestampMs; + } + if (a.jobId && b.jobId && a.jobId !== b.jobId) { + return a.jobId - b.jobId; + } + return a.fileName.localeCompare(b.fileName, undefined, { numeric: true, sensitivity: 'base' }); + }); +} + +function recoverCdJobArtifactsForImport(options = {}) { + const currentRawPath = String(options.currentRawPath || '').trim() || null; + const settings = options.settings && typeof options.settings === 'object' ? options.settings : {}; + const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object' + ? options.baseMetadata + : {}; + const rawPathCandidates = Array.isArray(options.rawPathCandidates) + ? options.rawPathCandidates + : []; + const relatedJobIds = Array.isArray(options.relatedJobIds) + ? options.relatedJobIds + : []; + const excludeJobIds = Array.isArray(options.excludeJobIds) + ? options.excludeJobIds + : []; + const logSources = findRelatedJobLogSources({ + rawPathCandidates: [currentRawPath, ...rawPathCandidates], + relatedJobIds, + excludeJobIds + }); + const logLines = logSources.flatMap((source) => source.lines || []); + + let formatFromLogs = null; + let selectedTracksFromLogs = []; + let selectedTracksLogSaysAll = false; + let cdparanoiaCmd = null; + const explicitOutputDirs = []; + const outputFilePathsFromLogs = []; + const seenOutputFilePath = new Set(); + + for (const line of logLines) { + const message = String(line || '').replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/, ''); + if (!message) { + continue; + } + + const startMatch = message.match(/CD-Rip gestartet:\s*Format=([a-z0-9]+)\s*,\s*Tracks=(.+)$/i); + if (startMatch) { + formatFromLogs = normalizeCdFormat(startMatch[1]) || formatFromLogs; + const selectedTrackText = String(startMatch[2] || '').trim(); + if (/^alle$/i.test(selectedTrackText)) { + selectedTracksLogSaysAll = true; + selectedTracksFromLogs = []; + } else { + selectedTracksFromLogs = selectedTrackText + .split(',') + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + } + } + + const completionMatch = message.match(/CD-Rip abgeschlossen\.\s*Ausgabe:\s*(.+)$/i); + if (completionMatch) { + const outputDir = normalizeAudioPathToken(completionMatch[1]) || String(completionMatch[1] || '').trim(); + if (outputDir) { + explicitOutputDirs.push(outputDir); + } + } + + if (!cdparanoiaCmd) { + const ripMatch = message.match(/Promptkette \[Rip \d+\/\d+]:\s*(.+)$/i); + if (ripMatch) { + cdparanoiaCmd = extractCommandToken(ripMatch[1]); + } + } + + for (const token of extractAbsolutePathTokens(message)) { + const ext = path.extname(token).toLowerCase(); + if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + continue; + } + if (CD_RAW_TRACK_FILE_PATTERN.test(path.basename(token))) { + continue; + } + if (!seenOutputFilePath.has(token)) { + seenOutputFilePath.add(token); + outputFilePathsFromLogs.push(token); + } + } + } + + const outputPath = pickPreferredExistingPath([ + ...(options.fallbackOutputPath ? [options.fallbackOutputPath] : []), + ...explicitOutputDirs, + ...outputFilePathsFromLogs.map((filePath) => path.dirname(filePath)) + ]); + + const outputAudioFiles = outputPath ? collectAudioFilesRecursively(outputPath) : []; + const parsedOutputTracks = assignMissingTrackPositions( + (outputAudioFiles.length > 0 ? outputAudioFiles : outputFilePathsFromLogs) + .map((filePath) => parseCdOutputTrackFromPath(filePath)) + .filter(Boolean) + ); + + const rawTracks = []; + if (currentRawPath && fs.existsSync(currentRawPath)) { + try { + const rawEntries = fs.readdirSync(currentRawPath, { withFileTypes: true }); + for (const entry of rawEntries) { + if (!entry.isFile()) { + continue; + } + const match = entry.name.match(CD_RAW_TRACK_FILE_PATTERN); + if (!match) { + continue; + } + const parsedPosition = Number(match[1]); + if (!Number.isFinite(parsedPosition) || parsedPosition <= 0) { + continue; + } + rawTracks.push({ + position: Math.trunc(parsedPosition), + title: `Track ${Math.trunc(parsedPosition)}`, + artist: null + }); + } + } catch (_error) { + // ignore raw dir read errors during best-effort import recovery + } + } + + const folderMetadata = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || '')); + const outputFolderMetadata = parseCdFolderMetadataFromOutputDir(outputPath); + const selectedTrackSet = new Set( + selectedTracksFromLogs + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const trackPositionSet = new Set(); + for (const track of rawTracks) { + trackPositionSet.add(track.position); + } + for (const track of parsedOutputTracks) { + if (Number.isFinite(Number(track?.position)) && Number(track.position) > 0) { + trackPositionSet.add(Math.trunc(Number(track.position))); + } + } + for (const position of selectedTrackSet) { + trackPositionSet.add(position); + } + + const trackPositions = Array.from(trackPositionSet).sort((a, b) => a - b); + const outputTrackByPosition = new Map( + parsedOutputTracks + .filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0) + .map((track) => [Math.trunc(Number(track.position)), track]) + ); + const rawTrackByPosition = new Map( + rawTracks + .filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0) + .map((track) => [Math.trunc(Number(track.position)), track]) + ); + + const tracks = trackPositions.map((position) => { + const outputTrack = outputTrackByPosition.get(position) || null; + const rawTrack = rawTrackByPosition.get(position) || null; + return { + position, + title: String(outputTrack?.title || rawTrack?.title || `Track ${position}`).trim() || `Track ${position}`, + artist: String(outputTrack?.artist || rawTrack?.artist || '').trim() || null, + selected: selectedTrackSet.size > 0 ? selectedTrackSet.has(position) : true + }; + }); + + const selectedTracks = selectedTracksLogSaysAll || selectedTrackSet.size === 0 + ? trackPositions + : trackPositions.filter((position) => selectedTrackSet.has(position)); + const artistHints = [ + ...parsedOutputTracks.map((track) => track.artist), + ...tracks.map((track) => track.artist), + baseMetadata.artist, + outputFolderMetadata.artist + ].filter(Boolean); + const formatHints = [ + formatFromLogs, + ...parsedOutputTracks.map((track) => track.format) + ].filter(Boolean); + const inferredArtist = findMostCommonString(artistHints); + const inferredFormat = findMostCommonString(formatHints); + const normalizedYear = Number(baseMetadata.year); + const year = Number.isFinite(normalizedYear) && normalizedYear > 0 + ? Math.trunc(normalizedYear) + : (outputFolderMetadata.year || folderMetadata.year || null); + const title = String( + baseMetadata.title + || baseMetadata.album + || outputFolderMetadata.album + || folderMetadata.title + || '' + ).trim() || null; + const artist = String(baseMetadata.artist || inferredArtist || '').trim() || null; + const selectedMetadata = { + title, + album: title, + artist, + year + }; + const outputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const outputPathFromMetadata = findCdOutputDirByMetadata({ + settings, + baseMetadata: selectedMetadata, + outputTemplate + }); + const resolvedOutputPath = outputPath || outputPathFromMetadata || null; + + return { + logSources, + outputPath: resolvedOutputPath, + outputPathExists: Boolean(resolvedOutputPath && fs.existsSync(resolvedOutputPath)), + format: normalizeCdFormat(inferredFormat), + tracks, + selectedTracks, + selectedMetadata, + cdparanoiaCmd: String(cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia', + outputTemplate, + importedLogFiles: logSources.map((source) => ({ + fileName: source.fileName, + jobId: source.jobId, + matchedBy: Array.isArray(source.matchedBy) ? source.matchedBy : [], + lineCount: Array.isArray(source.lines) ? source.lines.length : 0 + })) + }; +} + function deleteFilesRecursively(rootPath, keepRoot = true) { const result = { filesDeleted: 0, @@ -1263,6 +2044,525 @@ class HistoryService { }; } + buildRecoveredCdEncodePlan(recovery = {}) { + const tracks = Array.isArray(recovery?.tracks) ? recovery.tracks : []; + const selectedTracks = Array.isArray(recovery?.selectedTracks) ? recovery.selectedTracks : []; + const format = normalizeCdFormat(recovery?.format) || null; + const outputTemplate = String(recovery?.outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + + if (tracks.length === 0 && selectedTracks.length === 0 && !format) { + return null; + } + + return { + format, + formatOptions: {}, + selectedTracks: selectedTracks.length > 0 + ? selectedTracks + : tracks + .map((track) => Number(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)), + tracks, + outputTemplate, + recoveredFrom: { + source: 'orphan_raw_import', + importedLogFiles: Array.isArray(recovery?.importedLogFiles) ? recovery.importedLogFiles : [], + outputPath: recovery?.outputPath || null, + outputPathExists: Boolean(recovery?.outputPathExists) + } + }; + } + + buildOrphanRawImportMakemkvInfo(options = {}) { + const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other'; + const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString(); + const rawPath = String(options.rawPath || '').trim() || null; + const existingInfo = options.existingInfo && typeof options.existingInfo === 'object' + ? options.existingInfo + : {}; + const recovery = options.recovery && typeof options.recovery === 'object' + ? options.recovery + : null; + const previousImportContext = existingInfo.importContext && typeof existingInfo.importContext === 'object' + ? existingInfo.importContext + : {}; + const nextImportContext = { + ...previousImportContext, + requestedRawPath: String(options.requestedRawPath || previousImportContext.requestedRawPath || rawPath || '').trim() || null, + sourceFolderJobId: normalizeJobIdValue( + options.sourceFolderJobId + ?? previousImportContext.sourceFolderJobId + ?? null + ) + }; + + if (recovery?.outputPath) { + nextImportContext.recoveredOutputPath = recovery.outputPath; + } + if (Array.isArray(recovery?.importedLogFiles) && recovery.importedLogFiles.length > 0) { + nextImportContext.importedLogFiles = recovery.importedLogFiles; + } + + const nextInfo = { + ...existingInfo, + status: 'SUCCESS', + source: 'orphan_raw_import', + importedAt, + rawPath, + mediaProfile, + analyzeContext: { + ...(existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object' + ? existingInfo.analyzeContext + : {}), + mediaProfile + }, + importContext: nextImportContext + }; + + if (mediaProfile === 'cd' && recovery) { + if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { + nextInfo.tracks = recovery.tracks; + } + if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { + const recoverySelectedMetadata = Object.fromEntries( + Object.entries(recovery.selectedMetadata).filter(([, value]) => { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return true; + }) + ); + nextInfo.selectedMetadata = { + ...(existingInfo.selectedMetadata && typeof existingInfo.selectedMetadata === 'object' + ? existingInfo.selectedMetadata + : {}), + ...recoverySelectedMetadata + }; + } + nextInfo.cdparanoiaCmd = String( + recovery.cdparanoiaCmd + || existingInfo.cdparanoiaCmd + || 'cdparanoia' + ).trim() || 'cdparanoia'; + nextInfo.importRecovery = { + source: 'orphan_raw_import', + importedAt, + logFiles: Array.isArray(recovery.importedLogFiles) ? recovery.importedLogFiles : [], + outputPath: recovery.outputPath || null, + outputPathExists: Boolean(recovery.outputPathExists), + recoveredFormat: normalizeCdFormat(recovery.format), + selectedTracks: Array.isArray(recovery.selectedTracks) ? recovery.selectedTracks : [] + }; + } + + return nextInfo; + } + + async restoreImportedProcessLog(jobId, logSources = [], options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + const normalizedSources = (Array.isArray(logSources) ? logSources : []) + .filter((source) => source && source.filePath && Array.isArray(source.lines) && source.lines.length > 0); + const importInfo = options.importInfo && typeof options.importInfo === 'object' + ? options.importInfo + : null; + const shouldAppend = Boolean(options.append); + if (normalizedSources.length === 0 && !importInfo) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + const filePath = toProcessLogPath(normalizedJobId); + if (!filePath) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + await this.closeProcessLog(normalizedJobId); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!shouldAppend) { + try { + fs.unlinkSync(filePath); + } catch (_error) { + // ignore missing file on overwrite + } + } + + const lines = []; + if (normalizedSources.length > 0) { + const fileSummary = normalizedSources + .map((source) => source.fileName || path.basename(source.filePath)) + .filter(Boolean) + .join(', '); + lines.push( + `[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}` + ); + for (const source of normalizedSources) { + lines.push(...source.lines); + } + } + if (importInfo) { + lines.push( + `[${new Date().toISOString()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}` + ); + } + + if (lines.length === 0) { + return { + imported: false, + sourceCount: normalizedSources.length, + lineCount: 0 + }; + } + + try { + const prefix = shouldAppend && fs.existsSync(filePath) && fs.statSync(filePath).size > 0 ? '\n' : ''; + fs.writeFileSync(filePath, `${prefix}${lines.join('\n')}\n`, { + encoding: 'utf-8', + flag: shouldAppend ? 'a' : 'w' + }); + } catch (error) { + logger.warn('job:process-log:restore-failed', { + jobId: normalizedJobId, + path: filePath, + error: error?.message || String(error) + }); + return { + imported: false, + sourceCount: normalizedSources.length, + lineCount: 0 + }; + } + + return { + imported: normalizedSources.length > 0, + sourceCount: normalizedSources.length, + lineCount: lines.length + }; + } + + async resolveOrphanImportSourceJob(options = {}) { + const candidateJobIds = Array.isArray(options.candidateJobIds) + ? options.candidateJobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + const desiredMediaProfile = normalizeMediaTypeValue(options.mediaProfile) || null; + if (candidateJobIds.length === 0) { + return null; + } + + const uniqueCandidateIds = Array.from(new Set(candidateJobIds)); + const db = await getDb(); + const placeholders = uniqueCandidateIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT * FROM jobs WHERE id IN (${placeholders})`, + uniqueCandidateIds + ); + if (!Array.isArray(rows) || rows.length === 0) { + return null; + } + + const byId = new Map(rows.map((row) => [normalizeJobIdValue(row?.id), row])); + let best = null; + let bestScore = -1; + + for (const candidateId of uniqueCandidateIds) { + const row = byId.get(candidateId); + if (!row) { + continue; + } + + const makemkvInfo = parseJsonSafe(row.makemkv_info_json, {}); + const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const rowMediaProfile = normalizeMediaTypeValue( + makemkvInfo?.mediaProfile + || makemkvInfo?.analyzeContext?.mediaProfile + || row?.media_type + ); + const posterCandidate = String( + row?.poster_url + || selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || '' + ).trim() || null; + const hasTitle = Boolean(String( + selectedMetadata?.title + || selectedMetadata?.album + || row?.title + || row?.detected_title + || '' + ).trim()); + const hasArtist = Boolean(String(selectedMetadata?.artist || '').trim()); + const hasPoster = Boolean(posterCandidate); + const hasExternalMetadata = Boolean( + row?.omdb_json + || row?.imdb_id + || selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.mbid + ); + const isFinished = String(row?.status || row?.last_state || '').trim().toUpperCase() === 'FINISHED'; + + let score = 0; + if (desiredMediaProfile && rowMediaProfile === desiredMediaProfile) { + score += 10; + } + if (hasTitle) { + score += 6; + } + if (desiredMediaProfile === 'cd' && hasArtist) { + score += 4; + } + if (hasPoster) { + score += 3; + } + if (hasExternalMetadata) { + score += 2; + } + if (isFinished) { + score += 1; + } + + if (score > bestScore) { + bestScore = score; + best = { + job: row, + makemkvInfo, + selectedMetadata, + mediaProfile: rowMediaProfile, + posterCandidate + }; + } + } + + return best; + } + + getPreferredPosterCandidateForImport(sourceContext = null, explicitPosterUrl = null) { + const explicit = String(explicitPosterUrl || '').trim() || null; + if (explicit) { + return explicit; + } + const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object' + ? sourceContext.job + : null; + const selectedMetadata = sourceContext?.selectedMetadata && typeof sourceContext.selectedMetadata === 'object' + ? sourceContext.selectedMetadata + : {}; + return String( + sourceJob?.poster_url + || selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || '' + ).trim() || null; + } + + async restoreImportedPoster(jobId, sourceContext = null, explicitPosterUrl = null) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return null; + } + + const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object' + ? sourceContext.job + : null; + const sourceJobId = normalizeJobIdValue(sourceJob?.id); + const sourcePosterUrl = String(sourceJob?.poster_url || '').trim() || null; + if (sourceJobId && sourcePosterUrl && thumbnailService.isLocalUrl(sourcePosterUrl)) { + const copiedUrl = thumbnailService.copyThumbnail(sourceJobId, normalizedJobId); + if (copiedUrl) { + await this.updateJob(normalizedJobId, { poster_url: copiedUrl }); + return copiedUrl; + } + } + + const preferredPosterUrl = this.getPreferredPosterCandidateForImport(sourceContext, explicitPosterUrl); + if (!preferredPosterUrl || thumbnailService.isLocalUrl(preferredPosterUrl)) { + return null; + } + + const cachedPath = await thumbnailService.cacheJobThumbnail(normalizedJobId, preferredPosterUrl); + if (!cachedPath) { + return null; + } + + const promotedUrl = thumbnailService.promoteJobThumbnail(normalizedJobId); + if (!promotedUrl) { + return null; + } + + await this.updateJob(normalizedJobId, { poster_url: promotedUrl }); + return promotedUrl; + } + + async repairImportedCdJobArtifacts(job, settings = null) { + if (!job || typeof job !== 'object') { + return job; + } + + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + const mediaProfile = normalizeMediaTypeValue( + makemkvInfo?.mediaProfile + || makemkvInfo?.analyzeContext?.mediaProfile + || job?.media_type + ); + if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') { + return job; + } + + const hasTracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0; + const hasEncodePlan = Boolean(String(job?.encode_plan_json || '').trim()); + const hasOutputPath = Boolean(String(job?.output_path || '').trim()); + const hasExistingLog = hasProcessLogFile(job.id); + if (hasTracks && hasEncodePlan && hasOutputPath && hasExistingLog) { + return job; + } + + const importContext = makemkvInfo?.importContext && typeof makemkvInfo.importContext === 'object' + ? makemkvInfo.importContext + : {}; + const rawPathCandidates = [ + job?.raw_path, + makemkvInfo?.rawPath, + importContext?.requestedRawPath, + importContext?.originalRawPath + ].filter(Boolean); + const currentRawPath = String(job?.raw_path || makemkvInfo?.rawPath || '').trim() || null; + const rawFolderMeta = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || '')); + const recovery = recoverCdJobArtifactsForImport({ + currentRawPath, + rawPathCandidates, + relatedJobIds: [ + importContext?.sourceFolderJobId, + rawFolderMeta.folderJobId + ], + excludeJobIds: [], + settings: settings || {}, + baseMetadata: { + title: job?.title || makemkvInfo?.selectedMetadata?.title || null, + album: makemkvInfo?.selectedMetadata?.album || job?.title || null, + artist: makemkvInfo?.selectedMetadata?.artist || null, + year: makemkvInfo?.selectedMetadata?.year || job?.year || null + }, + fallbackOutputPath: job?.output_path || null + }); + const sourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [ + importContext?.sourceFolderJobId, + rawFolderMeta.folderJobId, + ...recovery.logSources.map((source) => source?.jobId) + ], + mediaProfile: 'cd' + }); + const sourceJob = sourceJobContext?.job || null; + const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' + ? sourceJobContext.selectedMetadata + : {}; + const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); + + const recoveryHasData = recovery.outputPath || recovery.logSources.length > 0 || recovery.tracks.length > 0; + if (!recoveryHasData && !sourceJob) { + return job; + } + + const importedAt = String(makemkvInfo?.importedAt || job?.end_time || new Date().toISOString()).trim() + || new Date().toISOString(); + const nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt, + rawPath: currentRawPath, + requestedRawPath: importContext?.requestedRawPath || currentRawPath, + sourceFolderJobId: importContext?.sourceFolderJobId || rawFolderMeta.folderJobId || null, + mediaProfile: 'cd', + existingInfo: makemkvInfo, + recovery + }); + const nextEncodePlan = this.buildRecoveredCdEncodePlan(recovery); + const patch = {}; + const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo); + + if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) { + patch.makemkv_info_json = nextMakemkvInfoJson; + } + if (!hasEncodePlan && nextEncodePlan) { + patch.encode_plan_json = JSON.stringify(nextEncodePlan); + } + if (!hasOutputPath && recovery.outputPath) { + patch.output_path = recovery.outputPath; + } + if (!job?.title && nextMakemkvInfo?.selectedMetadata?.title) { + patch.title = nextMakemkvInfo.selectedMetadata.title; + } else if (!job?.title && (sourceSelectedMetadata?.title || sourceSelectedMetadata?.album || sourceJob?.title)) { + patch.title = sourceSelectedMetadata.title || sourceSelectedMetadata.album || sourceJob.title; + } + if (!job?.year && nextMakemkvInfo?.selectedMetadata?.year) { + patch.year = nextMakemkvInfo.selectedMetadata.year; + } else if (!job?.year && (sourceSelectedMetadata?.year || sourceJob?.year)) { + patch.year = sourceSelectedMetadata.year ?? sourceJob.year ?? null; + } + if (!job?.imdb_id) { + const recoveredMbId = String( + sourceSelectedMetadata?.mbId + || sourceSelectedMetadata?.musicBrainzId + || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.mbid + || sourceJob?.imdb_id + || '' + ).trim() || null; + if (recoveredMbId) { + patch.imdb_id = recoveredMbId; + } + } + if (!job?.omdb_json && sourceJob?.omdb_json) { + patch.omdb_json = sourceJob.omdb_json; + patch.selected_from_omdb = Number(sourceJob.selected_from_omdb || 0); + } + if (!job?.poster_url && sourcePosterCandidate && !thumbnailService.isLocalUrl(sourcePosterCandidate)) { + patch.poster_url = sourcePosterCandidate; + } + + if (!hasExistingLog && recovery.logSources.length > 0) { + await this.restoreImportedProcessLog(job.id, recovery.logSources, { + importInfo: nextMakemkvInfo + }); + } + + let updatedJob = job; + if (Object.keys(patch).length > 0) { + updatedJob = await this.updateJob(job.id, patch); + } + + if (!job?.poster_url && sourcePosterCandidate) { + await this.restoreImportedPoster(job.id, sourceJobContext, sourcePosterCandidate); + updatedJob = await this.getJobById(job.id); + } + + return updatedJob; + } + async getJobById(jobId) { const db = await getDb(); return db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); @@ -1408,13 +2708,14 @@ class HistoryService { async getJobWithLogs(jobId, options = {}) { const db = await getDb(); const includeFsChecks = options?.includeFsChecks !== false; - const [job, settings] = await Promise.all([ + const [loadedJob, settings] = await Promise.all([ db.get('SELECT * FROM jobs WHERE id = ?', [jobId]), settingsService.getSettingsMap() ]); - if (!job) { + if (!loadedJob) { return null; } + const job = await this.repairImportedCdJobArtifacts(loadedJob, settings); const parsedTail = Number(options.logTailLines); const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 @@ -1750,46 +3051,122 @@ class HistoryService { } const detectedMediaType = detectOrphanMediaType(finalRawPath); + const initialSourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [metadata.folderJobId], + mediaProfile: detectedMediaType + }); + const initialSourceJob = initialSourceJobContext?.job || null; + const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' + ? initialSourceJobContext.selectedMetadata + : {}; const orphanPosterUrl = omdbById?.poster || null; + const cdRecovery = detectedMediaType === 'cd' + ? recoverCdJobArtifactsForImport({ + currentRawPath: finalRawPath, + rawPathCandidates: [ + absRawPath, + renamedRawPath, + finalRawPath + ], + relatedJobIds: [metadata.folderJobId], + excludeJobIds: [created.id], + settings, + baseMetadata: { + title: initialSourceSelectedMetadata?.title || initialSourceSelectedMetadata?.album || initialSourceJob?.title || metadata.title || null, + album: initialSourceSelectedMetadata?.album || initialSourceSelectedMetadata?.title || initialSourceJob?.title || metadata.title || null, + artist: initialSourceSelectedMetadata?.artist || null, + year: initialSourceSelectedMetadata?.year || initialSourceJob?.year || metadata.year || null + } + }) + : null; + const sourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [ + metadata.folderJobId, + ...(cdRecovery?.logSources || []).map((source) => source?.jobId) + ], + mediaProfile: detectedMediaType + }) || initialSourceJobContext; + const sourceJob = sourceJobContext?.job || null; + const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' + ? sourceJobContext.selectedMetadata + : {}; + const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); + const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; + const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt, + rawPath: finalRawPath, + requestedRawPath: absRawPath, + sourceFolderJobId: metadata.folderJobId || null, + mediaProfile: detectedMediaType, + existingInfo: sourceJobContext?.makemkvInfo || null, + recovery: cdRecovery + }); + const recoveredPosterUrl = orphanPosterUrl + || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) + || null; + const recoveredExternalId = String( + omdbById?.imdbId + || metadata.imdbId + || sourceSelectedMetadata?.mbId + || sourceSelectedMetadata?.musicBrainzId + || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.mbid + || sourceJob?.imdb_id + || '' + ).trim() || null; await this.updateJob(created.id, { status: 'FINISHED', last_state: 'FINISHED', - title: omdbById?.title || metadata.title || null, - year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : metadata.year, - imdb_id: omdbById?.imdbId || metadata.imdbId || null, - poster_url: orphanPosterUrl, - omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null, - selected_from_omdb: omdbById ? 1 : 0, + title: omdbById?.title + || sourceSelectedMetadata?.title + || sourceSelectedMetadata?.album + || sourceJob?.title + || metadata.title + || cdRecovery?.selectedMetadata?.title + || null, + year: Number.isFinite(Number(omdbById?.year)) + ? Number(omdbById.year) + : ( + sourceSelectedMetadata?.year + || sourceJob?.year + || metadata.year + || cdRecovery?.selectedMetadata?.year + || null + ), + imdb_id: recoveredExternalId, + poster_url: recoveredPosterUrl, + omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null), + selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0), rip_successful: 1, raw_path: finalRawPath, - output_path: null, + output_path: cdRecovery?.outputPath || null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: null, + encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : null, encode_input_path: null, encode_review_confirmed: 0, error_message: null, end_time: importedAt, - makemkv_info_json: JSON.stringify({ - status: 'SUCCESS', - source: 'orphan_raw_import', - importedAt, - rawPath: finalRawPath, - mediaProfile: detectedMediaType, - analyzeContext: { - mediaProfile: detectedMediaType - } - }) + makemkv_info_json: JSON.stringify(orphanImportInfo) }); + if (cdRecovery?.logSources?.length > 0) { + await this.restoreImportedProcessLog(created.id, cdRecovery.logSources, { + importInfo: orphanImportInfo + }); + } + // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) - if (orphanPosterUrl) { - thumbnailService.cacheJobThumbnail(created.id, orphanPosterUrl) - .then(() => { - const promotedUrl = thumbnailService.promoteJobThumbnail(created.id); - if (promotedUrl) return this.updateJob(created.id, { poster_url: promotedUrl }); - }) - .catch(() => {}); + if (orphanPosterUrl || sourcePosterCandidate) { + this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {}); + } + + if (!cdRecovery?.logSources?.length) { + await this.appendLog( + created.id, + 'SYSTEM', + `Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(orphanImportInfo)}` + ); } await this.appendLog( @@ -1808,6 +3185,13 @@ class HistoryService { : `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}` ); } + if (sourceJob) { + await this.appendLog( + created.id, + 'SYSTEM', + `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` + ); + } logger.info('job:import-orphan-raw', { jobId: created.id, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index a3794df..1c1b9f4 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -3857,6 +3857,8 @@ class PipelineService extends EventEmitter { this.queueEntries = []; this.queuePumpRunning = false; this.queueEntrySeq = 1; + this.pluginRegistryInitialized = false; + this.sourcePluginRegistry = null; this.lastQueueSnapshot = { maxParallelJobs: 1, runningCount: 0, @@ -3867,6 +3869,318 @@ class PipelineService extends EventEmitter { }; } + normalizeBooleanSetting(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; + } + + ensureSourcePluginRegistry() { + if (this.pluginRegistryInitialized) { + return this.sourcePluginRegistry; + } + + try { + const { registry } = require('../plugins/PluginRegistry'); + const { BluRayPlugin } = require('../plugins/BluRayPlugin'); + const { DVDPlugin } = require('../plugins/DVDPlugin'); + const { CdPlugin } = require('../plugins/CdPlugin'); + const { AudiobookPlugin } = require('../plugins/AudiobookPlugin'); + const plugins = [ + new BluRayPlugin(), + new DVDPlugin(), + new CdPlugin(), + new AudiobookPlugin() + ]; + for (const plugin of plugins) { + if (!registry.getPlugin(plugin.id)) { + registry.register(plugin); + } + } + this.sourcePluginRegistry = registry; + this.pluginRegistryInitialized = true; + return this.sourcePluginRegistry; + } catch (error) { + logger.warn('plugin-architecture:registry-init-failed', { + error: errorToMeta(error) + }); + return null; + } + } + + async isPluginArchitectureEnabled() { + return this.isPluginArchitectureEnabledForPlugin(null); + } + + getPluginArchitectureSettingKey(pluginId = null) { + const normalizedPluginId = String(pluginId || '').trim().toLowerCase(); + if (!normalizedPluginId) { + return null; + } + const byPluginId = { + bluray: 'use_plugin_architecture_bluray', + dvd: 'use_plugin_architecture_dvd', + cd: 'use_plugin_architecture_cd', + audiobook: 'use_plugin_architecture_audiobook' + }; + return byPluginId[normalizedPluginId] || null; + } + + isPluginArchitectureEnabledForSettings(settingsMap = null, pluginId = null) { + const settings = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const globalEnabled = this.normalizeBooleanSetting(settings.use_plugin_architecture); + if (!globalEnabled) { + return false; + } + + const pluginSettingKey = this.getPluginArchitectureSettingKey(pluginId); + if (!pluginSettingKey) { + return globalEnabled; + } + + if (!(pluginSettingKey in settings)) { + return true; + } + + return this.normalizeBooleanSetting(settings[pluginSettingKey]); + } + + async isPluginArchitectureEnabledForPlugin(pluginId = null) { + try { + const settingsMap = await settingsService.getSettingsMap(); + return this.isPluginArchitectureEnabledForSettings(settingsMap, pluginId); + } catch (error) { + logger.warn('plugin-architecture:settings-read-failed', { + error: errorToMeta(error), + pluginId: pluginId || null + }); + return false; + } + } + + async resolveAnalyzePlugin(discInfo = null) { + const enabled = await this.isPluginArchitectureEnabledForPlugin(null); + if (!enabled) { + return null; + } + const registry = this.ensureSourcePluginRegistry(); + if (!registry) { + return null; + } + const plugin = registry.findPlugin(discInfo); + if (!plugin?.id) { + return null; + } + const pluginEnabled = await this.isPluginArchitectureEnabledForPlugin(plugin.id); + if (!pluginEnabled) { + logger.info('plugin-architecture:plugin-disabled', { + pluginId: plugin.id, + mediaProfile: discInfo?.mediaProfile || null + }); + return null; + } + return plugin; + } + + sanitizePluginExecutionState(rawState = null) { + if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { + return null; + } + + const normalizeStage = (value) => { + const stage = String(value || '').trim().toLowerCase(); + return stage || 'unknown'; + }; + + const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || 'unknown'; + const pluginName = String(rawState.pluginName || '').trim() || pluginId; + const pluginFile = String(rawState.pluginFile || '').trim() || null; + const markerSource = String(rawState.markerSource || '').trim().toLowerCase() || 'plugin-file'; + const byStageRaw = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage) + ? rawState.byStage + : {}; + const byStage = {}; + for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) { + const normalizedStage = normalizeStage(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(rawState.stages) + ? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean) + : []; + const rawLastStage = String(rawState.lastStage || '').trim(); + const lastStage = rawLastStage ? normalizeStage(rawLastStage) : null; + const stages = Array.from(new Set([ + ...explicitStages, + ...Object.keys(byStage), + ...(lastStage ? [lastStage] : []) + ])); + + return { + markerSource, + pluginId, + pluginName, + pluginFile, + jobId: this.normalizeQueueJobId(rawState.jobId), + firstMarkedAt: String(rawState.firstMarkedAt || '').trim() || null, + lastMarkedAt: String(rawState.lastMarkedAt || '').trim() || null, + lastStage, + stages, + byStage + }; + } + + mergePluginExecutionState(existingState = null, nextState = null) { + const existing = this.sanitizePluginExecutionState(existingState); + const incoming = this.sanitizePluginExecutionState(nextState); + if (!existing) { + return incoming; + } + if (!incoming) { + return existing; + } + + const byStage = {}; + const stageKeys = new Set([ + ...Object.keys(existing.byStage || {}), + ...Object.keys(incoming.byStage || {}) + ]); + for (const stage of stageKeys) { + const previousMeta = existing.byStage?.[stage] || null; + const nextMeta = incoming.byStage?.[stage] || null; + const count = Number(previousMeta?.count || 0) + Number(nextMeta?.count || 0); + byStage[stage] = { + count: count > 0 ? Math.trunc(count) : 1, + lastMarkedAt: nextMeta?.lastMarkedAt || previousMeta?.lastMarkedAt || null + }; + } + + const stages = Array.from(new Set([ + ...(Array.isArray(existing.stages) ? existing.stages : []), + ...(Array.isArray(incoming.stages) ? incoming.stages : []) + ])); + + return { + markerSource: incoming.markerSource || existing.markerSource || 'plugin-file', + pluginId: incoming.pluginId || existing.pluginId || 'unknown', + pluginName: incoming.pluginName || existing.pluginName || incoming.pluginId || existing.pluginId || 'unknown', + pluginFile: incoming.pluginFile || existing.pluginFile || null, + jobId: this.normalizeQueueJobId(incoming.jobId || existing.jobId), + firstMarkedAt: existing.firstMarkedAt || incoming.firstMarkedAt || incoming.lastMarkedAt || existing.lastMarkedAt || null, + lastMarkedAt: incoming.lastMarkedAt || existing.lastMarkedAt || null, + lastStage: incoming.lastStage || existing.lastStage || (stages.length > 0 ? stages[stages.length - 1] : null), + stages, + byStage + }; + } + + withPluginExecutionMeta(info = null, executionState = null) { + const baseInfo = info && typeof info === 'object' && !Array.isArray(info) + ? { ...info } + : {}; + const mergedExecution = this.mergePluginExecutionState(baseInfo.pluginExecution, executionState); + if (!mergedExecution) { + return baseInfo; + } + baseInfo.pluginExecution = mergedExecution; + return baseInfo; + } + + async applyPluginExecutionMarker(marker = null, executionState = null) { + const mergedExecution = this.mergePluginExecutionState(null, executionState || marker); + const jobId = this.normalizeQueueJobId(mergedExecution?.jobId || marker?.jobId); + if (!mergedExecution || !jobId) { + return; + } + + const previousJobProgress = this.jobProgress.get(jobId) || {}; + const nextJobProgress = { + ...previousJobProgress, + state: previousJobProgress.state || this.snapshot.state, + progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0, + eta: previousJobProgress.eta ?? this.snapshot.eta ?? null, + statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null, + context: { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + pluginExecution: this.mergePluginExecutionState(previousJobProgress.context?.pluginExecution, mergedExecution) + } + }; + this.jobProgress.set(jobId, nextJobProgress); + + const snapshotJobId = this.normalizeQueueJobId(this.snapshot.activeJobId || this.snapshot.context?.jobId); + if (snapshotJobId === jobId) { + this.snapshot = { + ...this.snapshot, + context: { + ...(this.snapshot.context && typeof this.snapshot.context === 'object' + ? this.snapshot.context + : {}), + pluginExecution: this.mergePluginExecutionState(this.snapshot.context?.pluginExecution, mergedExecution) + } + }; + await this.persistSnapshot(false); + } + + const cdDriveEntry = this._getCdDriveByJobId(jobId); + if (cdDriveEntry?.devicePath) { + const existingDrive = this.cdDrives.get(cdDriveEntry.devicePath); + if (existingDrive) { + this.cdDrives.set(cdDriveEntry.devicePath, { + ...existingDrive, + context: { + ...(existingDrive.context && typeof existingDrive.context === 'object' + ? existingDrive.context + : {}), + pluginExecution: this.mergePluginExecutionState(existingDrive.context?.pluginExecution, mergedExecution) + } + }); + } + } + + wsService.broadcast('PIPELINE_PROGRESS', { + state: nextJobProgress.state || this.snapshot.state, + activeJobId: jobId, + progress: nextJobProgress.progress ?? 0, + eta: nextJobProgress.eta ?? null, + statusText: nextJobProgress.statusText ?? null, + contextPatch: { + pluginExecution: nextJobProgress.context.pluginExecution + } + }); + } + + async buildPluginContext(pluginId, extra = {}) { + const { PluginContext } = require('../plugins/PluginContext'); + const normalizedJobId = this.normalizeQueueJobId(extra?.jobId ?? extra?.job?.id); + return new PluginContext({ + settings: settingsService, + db: await getDb(), + logger: require('./logger').child(`PLUGIN:${String(pluginId || 'unknown').trim().toUpperCase() || 'UNKNOWN'}`), + websocket: wsService, + processRunner: { spawnTrackedProcess }, + emitProgress: () => {}, + emitState: () => {}, + onPluginExecution: (marker, aggregateState) => { + void this.applyPluginExecutionMarker(marker, aggregateState); + }, + extra: { + ...extra, + jobId: normalizedJobId, + pluginId + } + }); + } + isRipSuccessful(job = null) { if (Number(job?.rip_successful || 0) === 1) { return true; @@ -4402,6 +4716,34 @@ class PipelineService extends EventEmitter { return null; } + _releaseCdDrive(devicePath, options = {}) { + const normalizedPath = String(devicePath || '').trim(); + if (!normalizedPath) { + return; + } + + const existing = this.cdDrives.get(normalizedPath) || null; + const fallbackDevice = options?.device && typeof options.device === 'object' + ? options.device + : (existing?.device && typeof existing.device === 'object' + ? existing.device + : { path: normalizedPath, mediaProfile: 'cd' }); + + this._setCdDriveState(normalizedPath, { + state: 'DISC_DETECTED', + jobId: null, + device: fallbackDevice, + progress: 0, + eta: null, + statusText: null, + context: { + device: fallbackDevice, + devicePath: normalizedPath, + mediaProfile: 'cd' + } + }); + } + normalizeParallelJobsLimit(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 1) { @@ -6051,10 +6393,13 @@ class PipelineService extends EventEmitter { ...device, mediaProfile }; + const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile); // Route audio CDs to the dedicated CD pipeline if (mediaProfile === 'cd') { - return this.analyzeCd(deviceWithProfile); + return this.analyzeCd(deviceWithProfile, { + plugin: analyzePlugin?.id === 'cd' ? analyzePlugin : null + }); } const job = await historyService.createJob({ @@ -6064,18 +6409,50 @@ class PipelineService extends EventEmitter { }); try { - const omdbCandidates = await omdbService.search(detectedTitle).catch(() => []); + let effectiveDetectedTitle = detectedTitle; + let omdbCandidates = null; + let pluginExecution = null; + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: deviceWithProfile, + jobId: job.id + }); + try { + const pluginResult = await analyzePlugin.analyze(device.path, job, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + if (Array.isArray(pluginResult?.omdbCandidates)) { + omdbCandidates = pluginResult.omdbCandidates; + } + logger.info('plugin:analyze:used', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:fallback-legacy', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile, + error: errorToMeta(error) + }); + } + } + if (!Array.isArray(omdbCandidates)) { + omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); + } logger.info('metadata:prepare:result', { jobId: job.id, - detectedTitle, + detectedTitle: effectiveDetectedTitle, omdbCandidateCount: omdbCandidates.length }); - await historyService.updateJob(job.id, { - status: 'METADATA_SELECTION', - last_state: 'METADATA_SELECTION', - detected_title: detectedTitle, - makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + const prepareInfo = this.withPluginExecutionMeta( + this.withAnalyzeContextMediaProfile({ phase: 'PREPARE', preparedAt: nowIso(), analyzeContext: { @@ -6084,12 +6461,20 @@ class PipelineService extends EventEmitter { selectedPlaylist: null, selectedTitleId: null } - }, mediaProfile)) + }, mediaProfile), + pluginExecution + ); + + await historyService.updateJob(job.id, { + status: 'METADATA_SELECTION', + last_state: 'METADATA_SELECTION', + detected_title: effectiveDetectedTitle, + makemkv_info_json: JSON.stringify(prepareInfo) }); await historyService.appendLog( job.id, 'SYSTEM', - `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${detectedTitle}".` + `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` ); const runningJobs = await historyService.getRunningJobs(); @@ -6104,15 +6489,18 @@ class PipelineService extends EventEmitter { context: { jobId: job.id, device: deviceWithProfile, - detectedTitle, - detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback', + detectedTitle: effectiveDetectedTitle, + detectedTitleSource: effectiveDetectedTitle !== detectedTitle + ? 'plugin' + : (device.discLabel ? 'discLabel' : 'fallback'), omdbCandidates, mediaProfile, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], selectedPlaylist: null, - selectedTitleId: null + selectedTitleId: null, + ...(pluginExecution ? { pluginExecution } : {}) } }); } else { @@ -6125,12 +6513,12 @@ class PipelineService extends EventEmitter { void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', - message: `Job #${job.id}: ${detectedTitle} (${omdbCandidates.length} Treffer)` + message: `Job #${job.id}: ${effectiveDetectedTitle} (${omdbCandidates.length} Treffer)` }); return { jobId: job.id, - detectedTitle, + detectedTitle: effectiveDetectedTitle, omdbCandidates }; } catch (error) { @@ -8107,6 +8495,98 @@ class PipelineService extends EventEmitter { return this.startPreparedJob(sourceJobId); } + if (reencodeMediaProfile === 'cd') { + const cdReencodeSettings = await settingsService.getEffectiveSettingsMap('cd'); + const resolvedCdRawPath = this.resolveCurrentRawPathForSettings( + cdReencodeSettings, + 'cd', + sourceJob.raw_path + ); + if (!resolvedCdRawPath) { + const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht gefunden (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + // WAV-Dateien im RAW-Verzeichnis suchen + const wavFiles = fs.existsSync(resolvedCdRawPath) + ? fs.readdirSync(resolvedCdRawPath).filter((f) => f.endsWith('.cdda.wav')) + : []; + if (wavFiles.length === 0) { + const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); + error.statusCode = 400; + throw error; + } + + const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}; + const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; + const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object' + ? cdMkInfo.selectedMetadata + : {}; + const tocTracks = Array.isArray(cdMkInfo?.tracks) && cdMkInfo.tracks.length > 0 + ? cdMkInfo.tracks + : (Array.isArray(cdEncodePlan?.tracks) ? cdEncodePlan.tracks : []); + const selectedTrackPositions = normalizeCdTrackPositionList( + Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0 + ? cdEncodePlan.selectedTracks + : tocTracks.map((t) => Number(t?.position)).filter((p) => Number.isFinite(p) && p > 0) + ); + const format = String(cdEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac'; + const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object' + ? cdEncodePlan.formatOptions + : {}; + const cdOutputTemplate = String( + cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE + ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const cdOutputBaseDir = String(cdReencodeSettings.movie_dir || '').trim() + || String(cdReencodeSettings.raw_dir || '').trim() + || settingsService.DEFAULT_CD_DIR; + const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim(); + const outputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate); + + await historyService.resetProcessLog(sourceJobId); + await historyService.updateJob(sourceJobId, { + status: 'CD_RIPPING', + last_state: 'CD_RIPPING', + start_time: new Date().toISOString(), + end_time: null, + error_message: null, + output_path: outputDir + }); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}` + ); + + this._runCdRip({ + jobId: sourceJobId, + devicePath: null, + cdparanoiaCmd: 'cdparanoia', + rawWavDir: resolvedCdRawPath, + rawBaseDir: null, + cdMetadataBase: null, + outputDir, + format, + formatOptions, + outputTemplate: cdOutputTemplate, + rawOwner: null, + outputOwner: cdOutputOwner, + selectedTrackPositions, + tocTracks, + selectedMeta, + encodePlan: cdEncodePlan, + skipRip: true + }).catch((error) => { + logger.error('reencodeFromRaw:cd:background-failed', { jobId: sourceJobId, error: errorToMeta(error) }); + this.failJob(sourceJobId, 'CD_ENCODING', error).catch((failError) => { + logger.error('reencodeFromRaw:cd:failJob-failed', { jobId: sourceJobId, error: errorToMeta(failError) }); + }); + }); + + return { jobId: sourceJobId, started: true, queued: false }; + } + const ripSuccessful = this.isRipSuccessful(sourceJob); if (!ripSuccessful) { const error = new Error( @@ -9999,6 +10479,8 @@ class PipelineService extends EventEmitter { `${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.` ); } + const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }).catch(() => null); + if (devicePath) { diskDetectionService.lockDevice(devicePath, { jobId, @@ -10007,14 +10489,28 @@ class PipelineService extends EventEmitter { }); } try { - makemkvInfo = await this.runCommand({ - jobId, - stage: 'RIPPING', - source: 'MAKEMKV_RIP', - cmd: ripConfig.cmd, - args: ripConfig.args, - parser: parseMakeMkvProgress - }); + if (ripPlugin) { + // Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand() + const ripCtx = await this.buildPluginContext(ripPlugin.id, { + jobId, + rawJobDir, + deviceInfo: device, + selectedTitleId: effectiveSelectedTitleId, + backupOutputBase, + runCommand: this.runCommand.bind(this) + }); + makemkvInfo = await ripPlugin.rip(job, ripCtx); + } else { + // Legacy-Pfad + makemkvInfo = await this.runCommand({ + jobId, + stage: 'RIPPING', + source: 'MAKEMKV_RIP', + cmd: ripConfig.cmd, + args: ripConfig.args, + parser: parseMakeMkvProgress + }); + } } finally { if (devicePath) { diskDetectionService.unlockDevice(devicePath, { @@ -10039,10 +10535,18 @@ class PipelineService extends EventEmitter { } const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); + // Nach dem Plugin-Rip hat jobProgress die aktuelle pluginExecution (analyze + rip). + // Nach Legacy-Rip nehmen wir die analyze-Phase aus der DB. + const postRipPluginExecution = this.sanitizePluginExecutionState( + this.jobProgress.get(Number(jobId))?.context?.pluginExecution + ?? mkInfoBeforeRip?.pluginExecution + ?? null + ); await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...makemkvInfo, - analyzeContext: mkInfoBeforeRip?.analyzeContext || null + analyzeContext: mkInfoBeforeRip?.analyzeContext || null, + pluginExecution: postRipPluginExecution || null }, mediaProfile)), rip_successful: 1 }); @@ -10177,15 +10681,6 @@ class PipelineService extends EventEmitter { const sourceStatus = String(sourceJob.status || '').trim().toUpperCase(); const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase(); - const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) - || ['ERROR', 'CANCELLED'].includes(sourceLastState); - if (!retryable) { - const error = new Error( - `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` - ); - error.statusCode = 409; - throw error; - } const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); @@ -10196,6 +10691,18 @@ class PipelineService extends EventEmitter { const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; + // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs) + const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) + || ['ERROR', 'CANCELLED'].includes(sourceLastState) + || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); + if (!retryable) { + const error = new Error( + `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` + ); + error.statusCode = 409; + throw error; + } + let cdRetryConfig = null; if (isCdRetry) { const normalizeTrackPosition = (value) => { @@ -10208,11 +10715,28 @@ class PipelineService extends EventEmitter { const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks) ? sourceMakemkvInfo.tracks : (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []); + // Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen) if (sourceTracks.length === 0) { - const error = new Error('Retry nicht möglich: keine CD-Trackdaten im Quelljob vorhanden.'); - error.statusCode = 400; - throw error; - } + cdRetryConfig = { + format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', + formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' + ? sourceEncodePlan.formatOptions + : {}, + selectedTracks: [], + tracks: [], + metadata: { + title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', + artist: sourceMakemkvInfo?.selectedMetadata?.artist || null, + year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null, + mbId: sourceMakemkvInfo?.selectedMetadata?.mbId || null, + coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null + }, + selectedPreEncodeScriptIds: [], + selectedPostEncodeScriptIds: [], + selectedPreEncodeChainIds: [], + selectedPostEncodeChainIds: [] + }; + } else { const selectedTracks = normalizeCdTrackPositionList( Array.isArray(sourceEncodePlan?.selectedTracks) ? sourceEncodePlan.selectedTracks @@ -10252,6 +10776,7 @@ class PipelineService extends EventEmitter { selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []), selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) }; + } // end else (sourceTracks.length > 0) } else if (!isAudiobookRetry) { const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( @@ -11570,6 +12095,9 @@ class PipelineService extends EventEmitter { statusText: message, context: failContext }); + if (isCancelled) { + this._releaseCdDrive(cdDevicePath); + } } } else { await this.setState(finalState, { @@ -12483,7 +13011,7 @@ class PipelineService extends EventEmitter { // ── CD Pipeline ───────────────────────────────────────────────────────────── - async analyzeCd(device) { + async analyzeCd(device, options = {}) { const devicePath = String(device?.path || '').trim(); const detectedTitle = String( device?.discLabel || device?.label || device?.model || 'Audio CD' @@ -12499,7 +13027,8 @@ class PipelineService extends EventEmitter { try { const settings = await settingsService.getSettingsMap(); - const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + let effectiveDetectedTitle = detectedTitle; + let cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; // Read TOC this._setCdDriveState(devicePath, { @@ -12512,7 +13041,48 @@ class PipelineService extends EventEmitter { context: { jobId: job.id, device, mediaProfile: 'cd' } }); - const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); + let tracks = null; + let pluginExecution = null; + const analyzePlugin = options?.plugin && typeof options.plugin === 'object' + ? options.plugin + : null; + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: device, + jobId: job.id + }); + try { + const pluginResult = await analyzePlugin.analyze(devicePath, job, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + if (Array.isArray(pluginResult?.tracks) && pluginResult.tracks.length > 0) { + tracks = pluginResult.tracks; + } + const pluginCmd = String(pluginResult?.cdparanoiaCmd || '').trim(); + if (pluginCmd) { + cdparanoiaCmd = pluginCmd; + } + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + logger.info('plugin:analyze:used', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile: 'cd' + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:cd:fallback-legacy', { + jobId: job.id, + pluginId: analyzePlugin.id, + error: errorToMeta(error) + }); + } + } + + if (!Array.isArray(tracks) || tracks.length === 0) { + tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); + } logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length }); if (!tracks.length) { const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).'); @@ -12526,14 +13096,15 @@ class PipelineService extends EventEmitter { preparedAt: nowIso(), cdparanoiaCmd, tracks, - detectedTitle + detectedTitle: effectiveDetectedTitle }; + const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution); await historyService.updateJob(job.id, { status: 'CD_METADATA_SELECTION', last_state: 'CD_METADATA_SELECTION', - detected_title: detectedTitle, - makemkv_info_json: JSON.stringify(cdInfo) + detected_title: effectiveDetectedTitle, + makemkv_info_json: JSON.stringify(persistedCdInfo) }); await historyService.appendLog( job.id, @@ -12557,12 +13128,13 @@ class PipelineService extends EventEmitter { devicePath, cdparanoiaCmd, cdparanoiaCommandPreview, - detectedTitle, - tracks + detectedTitle: effectiveDetectedTitle, + tracks, + ...(pluginExecution ? { pluginExecution } : {}) } }); - return { jobId: job.id, detectedTitle, tracks }; + return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks }; } catch (error) { logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) }); await this.failJob(job.id, 'CD_ANALYZING', error); @@ -13197,7 +13769,8 @@ class PipelineService extends EventEmitter { selectedTrackPositions, tocTracks, selectedMeta, - encodePlan = null + encodePlan = null, + skipRip = false }) { const processKey = Number(jobId); let currentProcessHandle = null; @@ -13307,6 +13880,7 @@ class PipelineService extends EventEmitter { selectedTracks: selectedTrackPositions, tracks: tocTracks, meta: selectedMeta, + skipRip, onProcessHandle: bindProcessHandle, isCancelled: () => this.cancelRequestedByJob.has(processKey), onProgress: async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => { @@ -13490,6 +14064,7 @@ class PipelineService extends EventEmitter { selectedMetadata: selectedMeta } }); + this._releaseCdDrive(devicePath); void this.notifyPushover('job_finished', { title: 'Ripster - CD Rip erfolgreich', diff --git a/backend/src/services/websocketService.js b/backend/src/services/websocketService.js index 78ae2f3..317bc32 100644 --- a/backend/src/services/websocketService.js +++ b/backend/src/services/websocketService.js @@ -7,6 +7,17 @@ class WebSocketService { this.clients = new Set(); } + _removeClient(socket, logLevel = 'info', event = 'client:removed') { + if (!socket) { + return; + } + const deleted = this.clients.delete(socket); + if (!deleted) { + return; + } + logger[logLevel](event, { clients: this.clients.size }); + } + init(httpServer) { if (this.wss) { return; @@ -18,21 +29,32 @@ class WebSocketService { this.clients.add(socket); logger.info('client:connected', { clients: this.clients.size }); - socket.send( - JSON.stringify({ - type: 'WS_CONNECTED', - payload: { connectedAt: new Date().toISOString() } - }) - ); + try { + socket.send( + JSON.stringify({ + type: 'WS_CONNECTED', + payload: { connectedAt: new Date().toISOString() } + }) + ); + } catch (error) { + logger.warn('client:connected:initial-send-failed', { + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure'); + return; + } socket.on('close', () => { - this.clients.delete(socket); - logger.info('client:closed', { clients: this.clients.size }); + this._removeClient(socket, 'info', 'client:closed'); }); - socket.on('error', () => { - this.clients.delete(socket); - logger.warn('client:error', { clients: this.clients.size }); + socket.on('error', (error) => { + logger.warn('client:error', { + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(socket, 'warn', 'client:error:removed'); }); }); } @@ -55,8 +77,42 @@ class WebSocketService { }); for (const client of this.clients) { - if (client.readyState === client.OPEN) { - client.send(message); + if (!client || client.readyState !== client.OPEN) { + if (client && client.readyState === client.CLOSED) { + this._removeClient(client, 'info', 'client:pruned-closed'); + } + continue; + } + + try { + client.send(message, (error) => { + if (!error) { + return; + } + logger.warn('broadcast:send-failed', { + type, + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(client, 'warn', 'client:removed-after-send-failure'); + try { + client.terminate(); + } catch (_error) { + // noop + } + }); + } catch (error) { + logger.warn('broadcast:send-threw', { + type, + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(client, 'warn', 'client:removed-after-send-throw'); + try { + client.terminate(); + } catch (_terminateError) { + // noop + } } } } diff --git a/db/schema.sql b/db/schema.sql index 771c983..ffbee00 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -457,6 +457,26 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('use_plugin_architecture', 'Erweitert', 'Neue Plugin-Architektur (Beta)', 'boolean', 1, 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.', 'false', '[]', '{}', 9999); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('use_plugin_architecture_bluray', 'Erweitert', 'Beta: Blu-ray Plugin aktiv', 'boolean', 1, 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10000); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('use_plugin_architecture_dvd', 'Erweitert', 'Beta: DVD Plugin aktiv', 'boolean', 1, 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10001); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Beta: Audio-CD Plugin aktiv', 'boolean', 1, 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10002); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('use_plugin_architecture_audiobook', 'Erweitert', 'Beta: Audiobook Plugin aktiv', 'boolean', 1, 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10003); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook', 'true'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8cfaa48..17dd1a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.11.0-5", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.11.0-5", + "version": "0.12.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 1023bca..d9efa0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.11.0-5", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d419203..92dc4e8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; @@ -493,13 +493,16 @@ function App() { pendingExpandedJobId={pendingDashboardJobId} onPendingExpandedJobHandled={handleDashboardJobFocusConsumed} downloadSummary={downloadSummary} - /> + > + + } - /> - } /> - } /> - } /> - } /> + > + } /> + } /> + } /> + } /> + diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 86e350d..57a26c4 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { Dialog } from 'primereact/dialog'; import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; +import { resolveJobPluginExecution } from '../utils/pluginExecution'; import { getStatusLabel } from '../utils/statusPresentation'; const CD_FORMAT_LABELS = { @@ -465,6 +467,59 @@ function BoolState({ value }) { ); } +function resolveSelectionStateMeta({ selected, outcome }) { + if (!selected) { + return { + label: 'Nicht ausgewählt', + icon: 'pi-minus-circle', + className: 'track-selection-inline-neutral', + title: 'Nicht ausgewählt' + }; + } + if (outcome === 'success') { + return { + label: 'Ausgewählt', + icon: 'pi-check-circle', + className: 'job-step-inline-ok', + title: 'Ausgewählt und erfolgreich' + }; + } + if (outcome === 'error') { + return { + label: 'Ausgewählt', + icon: 'pi-times-circle', + className: 'job-step-inline-no', + title: 'Ausgewählt, aber nicht erfolgreich' + }; + } + if (outcome === 'cancelled') { + return { + label: 'Ausgewählt', + icon: 'pi-ban', + className: 'job-step-inline-warn', + title: 'Ausgewählt, aber abgebrochen' + }; + } + return { + label: 'Ausgewählt', + icon: 'pi-clock', + className: 'track-selection-inline-neutral', + title: 'Ausgewählt' + }; +} + +function SelectionStateNote({ selected, outcome }) { + const meta = resolveSelectionStateMeta({ selected, outcome }); + return ( + + + {meta.label} +