0.12.0 Begin neu Architecture

This commit is contained in:
2026-03-18 15:35:10 +00:00
parent d00191324b
commit 0a9cf6969f
30 changed files with 4570 additions and 183 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"watch": [
"src",
".env"
],
"ext": "js,json,env",
"ignore": [
"data/**",
"logs/**"
]
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.11.0-5",
"version": "0.12.0",
"private": true,
"type": "commonjs",
"scripts": {
+47
View File
@@ -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,
+448
View File
@@ -0,0 +1,448 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { SourcePlugin } = require('./PluginBase');
const audiobookService = require('../services/audiobookService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { ensureDir } = require('../utils/files');
/**
* Source-Plugin für Audiobooks (AAX-Dateien).
*
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
* - Kein Rip-Schritt — die AAX-Datei ist der Input
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
*
* Deshalb:
* rip() → No-Op (Datei liegt bereits im rawDir)
* encode() → ffmpeg-Encoding (Single-File oder per Chapter)
* review() → null (kein HandBrake-Preview)
*
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
* Datei-Uploads) oder eine Dateiendung im discInfo.
*
* Pflicht-Felder in ctx.extra für analyze():
* filePath - Absoluter Pfad zur .aax-Datei
*
* Pflicht-Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur .aax-Input-Datei
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
* outputFormat - 'm4b' | 'mp3' | 'flac'
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class AudiobookPlugin extends SourcePlugin {
get id() {
return 'audiobook';
}
get name() {
return 'Audiobook (AAX)';
}
get priority() {
return 5;
}
/**
* Erkennt Audiobooks.
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'audiobook' || profile === 'audio_book') {
return true;
}
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
}
/**
* Analysiert eine .aax-Datei mit ffprobe.
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
*
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
*/
async analyze(filePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const inputPath = ctx.extra?.filePath || filePath;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const settings = await ctx.settings.getSettingsMap();
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const originalName = path.basename(inputPath);
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
const probe = audiobookService.parseProbeOutput(captured.stdout);
if (!probe) {
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
error.statusCode = 500;
throw error;
}
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
ctx.logger.info('audiobook:analyze:done', {
jobId: job?.id,
title: metadata.title,
author: metadata.author,
chapterCount: metadata.chapters?.length || 0,
durationMs: metadata.durationMs
});
return metadata;
}
/**
* No-Op: Audiobooks haben keinen Rip-Schritt.
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
*/
async rip(_job, _ctx) {
_ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
// Für Audiobooks gibt es keinen separaten Rip-Schritt
}
/**
* Encodiert die .aax-Datei mit ffmpeg.
* Unterstützt Single-File (M4B) und kapitelweise Ausgabe (MP3, FLAC).
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const {
inputPath,
outputPath,
outputFormat = 'mp3',
formatOptions = {},
metadata = {},
activationBytes = null,
isSplitOutput = false,
chapterPlan = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
}
if (!outputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
}
if (!fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const settings = await ctx.settings.getSettingsMap();
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
ctx.logger.info('audiobook:encode:start', {
jobId: job?.id,
format: normalizedFormat,
isSplitOutput,
inputPath
});
if (isSplitOutput) {
await this._encodeChapters({
job, ctx, ffmpegCommand, inputPath, chapterPlan,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
} else {
await this._encodeSingleFile({
job, ctx, ffmpegCommand, inputPath, outputPath,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
}
ctx.logger.info('audiobook:encode:done', {
jobId: job?.id,
format: normalizedFormat,
outputPath
});
// Ergebnis für den Orchestrator ablegen
ctx.extra.encodeResult = {
outputPath,
format: normalizedFormat,
chapterCount: isSplitOutput
? (chapterPlan?.outputFiles?.length || 0)
: 1
};
return ctx.extra.encodeResult;
}
/**
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audiobooks.
*/
getSettingsSchema() {
return [
{
key: 'ffmpeg_command',
category: 'Tools',
label: 'FFmpeg Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
default_value: 'ffmpeg',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 232
},
{
key: 'ffprobe_command',
category: 'Tools',
label: 'FFprobe Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
default_value: 'ffprobe',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 233
},
{
key: 'output_template_audiobook',
category: 'Pfade',
label: 'Output Template (Audiobook)',
type: 'string',
required: 1,
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 735
}
];
}
// ── Private Encode-Methoden ──────────────────────────────────────────────────
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
ensureDir(path.dirname(outputPath));
const ffmpegConfig = audiobookService.buildEncodeCommand(
ffmpegCommand,
inputPath,
outputPath,
normalizedFormat,
normalizedOptions,
{
activationBytes,
metadata
}
);
ctx.logger.info('audiobook:encode:single-file', {
jobId: job?.id,
cmd: ffmpegConfig.cmd,
outputPath
});
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser,
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin' }
});
}
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
if (outputFiles.length === 0) {
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
}
for (let index = 0; index < outputFiles.length; index++) {
_assertNotCancelled(isCancelled);
const entry = outputFiles[index];
const chapter = entry?.chapter || {};
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
const startPct = (index / outputFiles.length) * 100;
const endPct = ((index + 1) / outputFiles.length) * 100;
ensureDir(path.dirname(entry.outputPath));
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
ffmpegCommand,
inputPath,
entry.outputPath,
normalizedFormat,
normalizedOptions,
metadata,
chapter,
outputFiles.length,
{ activationBytes }
);
ctx.logger.info('audiobook:encode:chapter', {
jobId: job?.id,
chapterIndex: index + 1,
chapterTotal: outputFiles.length,
chapterTitle,
outputPath: entry.outputPath
});
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
const scaledParser = baseParser
? (line) => {
const progress = baseParser(line);
if (!progress || progress.percent == null) {
return null;
}
return {
percent: startPct + (endPct - startPct) * (progress.percent / 100),
eta: null
};
}
: null;
ctx.emitProgress(
Math.round(startPct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser: scaledParser,
onProgress: (pct) => ctx.emitProgress(
Math.round(pct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
});
}
}
}
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
function _assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
throw error;
}
}
async function _runCaptured(cmd, args, _context = {}) {
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
try {
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
} catch (error) {
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
return {
stdout: String(error?.stdout || ''),
stderr: String(error?.stderr || ''),
exitCode: error?.code ?? 1
};
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
_assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { AudiobookPlugin };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für Blu-ray Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'bluray'
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
*/
class BluRayPlugin extends VideoDiscPlugin {
get id() {
return 'bluray';
}
get name() {
return 'Blu-ray';
}
get mediaProfile() {
return 'bluray';
}
/**
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
* theoretisch auf UDF-Discs matchen könnten.
*/
get priority() {
return 10;
}
/**
* Erkennt Blu-ray Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'bluray'
* @param {string} [discInfo.fstype] - 'udf'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
return true;
}
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
// vertrauen und nicht via Modell-Marker überschreiben.
if (profile) {
return false;
}
// Fallback: nur wenn kein mediaProfile bekannt ist.
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
return true;
}
return false;
}
}
module.exports = { BluRayPlugin };
+332
View File
@@ -0,0 +1,332 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const cdRipService = require('../services/cdRipService');
/**
* Source-Plugin für Audio-CDs.
*
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
*
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
* verzahnt (rip track → encode track → nächster Track).
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
*
* Erwartete ctx.extra-Felder für rip():
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
* selectedTracks, tracks, metadata, ...)
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
* outputTemplate - CD-Output-Template String
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
*/
class CdPlugin extends SourcePlugin {
get id() {
return 'cd';
}
get name() {
return 'Audio CD';
}
get priority() {
return 10;
}
/**
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
*
* Akzeptierte Werte: 'cd', 'audio_cd'
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
}
/**
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
*
* @param {string} devicePath - z.B. '/dev/sr0'
* @param {object} job - Job-Record (id wird für Logging genutzt)
* @param {PluginContext} ctx
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const detectedTitle = String(
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
).trim();
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
error.statusCode = 400;
throw error;
}
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
return {
tracks,
cdparanoiaCmd,
detectedTitle
};
}
/**
* Rippt und encodiert alle ausgewählten Tracks der CD.
*
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
*
* Pflicht-Felder in ctx.extra:
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata }
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
* outputDir - Finaler Ausgabeordner
* outputTemplate - Template-String für Dateinamen
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const {
ripConfig = {},
rawWavDir,
outputDir,
outputTemplate,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!rawWavDir) {
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
}
if (!outputDir) {
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
}
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim();
if (!devicePath) {
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
}
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
const formatOptions = ripConfig.formatOptions || {};
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
const incomingByPos = new Map(
incomingTracks
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
.map((t) => [Math.trunc(Number(t.position)), t])
);
const mergedTracks = tocTracks
.map((track) => {
const pos = Math.trunc(Number(track?.position));
if (!Number.isFinite(pos) || pos <= 0) {
return null;
}
const incoming = incomingByPos.get(pos) || null;
return {
...track,
position: pos,
title: incoming?.title || track.title || `Track ${pos}`,
artist: incoming?.artist || track.artist || null,
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
};
})
.filter(Boolean);
const selectedMeta = cdInfo.selectedMetadata || {};
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
? ripConfig.metadata
: {};
const meta = {
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
};
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
? ripConfig.selectedTracks
.map((v) => Math.trunc(Number(v)))
.filter((v) => Number.isFinite(v) && v > 0)
: [];
const selectedTrackPositions = rawSelectedPositions.length > 0
? rawSelectedPositions
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
ctx.logger.info('cd:rip:start', {
jobId: job?.id,
devicePath,
format,
trackCount: selectedTrackPositions.length
});
const result = await cdRipService.ripAndEncode({
jobId: job?.id,
devicePath,
cdparanoiaCmd,
rawWavDir,
outputDir,
format,
formatOptions,
selectedTracks: selectedTrackPositions,
tracks: mergedTracks,
meta,
outputTemplate: effectiveTemplate,
onProgress: (progressEvent) => {
const pct = Number(progressEvent?.percent ?? 0);
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
const trackIdx = progressEvent?.trackIndex || 1;
const trackTotal = progressEvent?.trackTotal || 1;
ctx.emitProgress(
Math.round(pct),
`${phase} Track ${trackIdx}/${trackTotal}`
);
},
onLog: (level, msg) => {
if (ctx.logger[level]) {
ctx.logger[level](msg, { jobId: job?.id });
}
},
onProcessHandle,
isCancelled,
context: { jobId: job?.id, source: 'CdPlugin' }
});
ctx.logger.info('cd:rip:done', {
jobId: job?.id,
format,
trackCount: result.trackCount,
outputDir: result.outputDir
});
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
ctx.extra.ripResult = result;
return result;
}
/**
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
*/
async encode(_job, _ctx) {
_ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
}
/**
* CDs brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audio CDs.
*/
getSettingsSchema() {
return [
{
key: 'cdparanoia_command',
category: 'Tools',
label: 'cdparanoia Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
default_value: 'cdparanoia',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 240
},
{
key: 'cd_output_template',
category: 'Pfade',
label: 'CD Output Template',
type: 'string',
required: 1,
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 730
}
];
}
}
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
function _safeParseJson(value) {
try {
return value ? JSON.parse(value) : null;
} catch {
return null;
}
}
function _normText(value) {
const s = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return s || null;
}
function _normYear(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
module.exports = { CdPlugin };
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für DVD-Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'dvd'
* - Dateisystem: UDF (1.02) oder ISO9660
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
*/
class DVDPlugin extends VideoDiscPlugin {
get id() {
return 'dvd';
}
get name() {
return 'DVD';
}
get mediaProfile() {
return 'dvd';
}
/**
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
*/
get priority() {
return 5;
}
/**
* Erkennt DVD-Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'dvd'
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'dvd') {
return true;
}
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
if (hasBlurayMarker) {
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
}
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
return true;
}
return false;
}
}
module.exports = { DVDPlugin };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/**
* Abstrakte Basisklasse für Source-Plugins.
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
*
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
*
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
*/
class SourcePlugin {
/**
* Eindeutiger Bezeichner des Plugins.
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
* @returns {string}
*/
get id() {
throw new Error(`${this.constructor.name}: id nicht implementiert`);
}
/**
* Anzeigename des Plugins (für Logs und UI).
* @returns {string}
*/
get name() {
throw new Error(`${this.constructor.name}: name nicht implementiert`);
}
/**
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
* Standard: 0
* @returns {number}
*/
get priority() {
return 0;
}
/**
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
* Wird vom PluginRegistry.findPlugin() aufgerufen.
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @param {string} [discInfo.devicePath]
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
}
/**
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
*
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
* @param {object} job - Bestehender Job-Record aus der DB
* @param {PluginContext} ctx
* @returns {Promise<object>} Analyse-Ergebnis
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
*/
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
}
/**
* Rippt das Medium in den RAW-Ordner.
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async rip(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
}
/**
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object|null>} Review-Daten oder null
*/
async review(job, ctx) { // eslint-disable-line no-unused-vars
return null;
}
/**
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async encode(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
}
/**
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
// Default: kein Finalize-Schritt notwendig
}
/**
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
* Wird vom Orchestrator bei cancel() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
* Wird vom Orchestrator vor retry() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Plugin-spezifische Settings-Schema-Einträge.
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
* und können zur Laufzeit in die DB eingetragen werden.
*
* Format je Eintrag:
* { key, category, label, type, required, description,
* default_value, options_json, validation_json, order_index }
*
* @returns {Array<object>}
*/
getSettingsSchema() {
return [];
}
}
module.exports = { SourcePlugin };
+194
View File
@@ -0,0 +1,194 @@
'use strict';
function normalizeExecutionStage(value) {
const stage = String(value || '').trim().toLowerCase();
return stage || 'unknown';
}
function normalizePluginExecutionState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
const pluginName = String(value.pluginName || '').trim() || pluginId;
const pluginFile = String(value.pluginFile || '').trim() || null;
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
const rawLastStage = String(value.lastStage || '').trim();
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
const jobIdRaw = Number(value.jobId);
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
? value.byStage
: {};
const byStage = {};
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
const normalizedStage = normalizeExecutionStage(stageKey);
const count = Number(stageMeta?.count);
byStage[normalizedStage] = {
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
};
}
const explicitStages = Array.isArray(value.stages)
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
: [];
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage),
...(lastStage ? [lastStage] : [])
]));
return {
markerSource,
pluginId,
pluginName,
pluginFile,
jobId,
firstMarkedAt,
lastMarkedAt,
lastStage,
stages,
byStage
};
}
function mergePluginExecutionState(existingState, marker) {
const existing = normalizePluginExecutionState(existingState);
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
if (!normalizedMarker) {
return existing;
}
const stage = normalizeExecutionStage(normalizedMarker.stage);
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
const previousStageMeta = existing?.byStage?.[stage] || null;
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
const nextByStage = {
...(existing?.byStage || {}),
[stage]: {
count: nextStageCount,
lastMarkedAt: markedAt
}
};
const nextStages = Array.from(new Set([
...(Array.isArray(existing?.stages) ? existing.stages : []),
stage
]));
return {
markerSource: 'plugin-file',
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
? Math.trunc(Number(normalizedMarker.jobId))
: (existing?.jobId || null),
firstMarkedAt: existing?.firstMarkedAt || markedAt,
lastMarkedAt: markedAt,
lastStage: stage,
stages: nextStages,
byStage: nextByStage
};
}
/**
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
*
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
*/
class PluginContext {
/**
* @param {object} options
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
* @param {object} options.websocket - websocketService (broadcast() etc.)
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
* @param {Function} options.emitState - (newState: string, context?: object) => void
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
*/
constructor({
settings,
db,
logger,
websocket,
processRunner,
emitProgress,
emitState,
onPluginExecution,
extra = {}
} = {}) {
this.settings = settings;
this.db = db;
this.logger = logger;
this.websocket = websocket;
this.processRunner = processRunner;
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
this.emitState = typeof emitState === 'function' ? emitState : () => {};
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
this.extra = extra;
this.pluginExecution = null;
}
markExecution(stage, payload = {}) {
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
const marker = {
markerSource: 'plugin-file',
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(payload?.pluginName || '').trim()
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(payload?.pluginFile || '').trim() || null,
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
stage: normalizeExecutionStage(stage),
markedAt: new Date().toISOString()
};
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
try {
this.onPluginExecution(marker, this.getPluginExecution());
} catch (error) {
if (this.logger && typeof this.logger.warn === 'function') {
this.logger.warn('plugin:execution:callback-failed', {
pluginId: marker.pluginId,
pluginFile: marker.pluginFile,
stage: marker.stage,
error: error?.message || String(error)
});
}
}
return this.getPluginExecution();
}
getPluginExecution() {
const normalized = normalizePluginExecutionState(this.pluginExecution);
if (!normalized) {
return null;
}
return {
...normalized,
stages: [...normalized.stages],
byStage: Object.fromEntries(
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
stage,
{
count: Number(meta?.count || 1),
lastMarkedAt: meta?.lastMarkedAt || null
}
])
)
};
}
}
module.exports = { PluginContext };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
/**
* Registry für alle Source-Plugins.
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
*
* Verwendung:
* const { registry } = require('./PluginRegistry');
* registry.register(new BluRayPlugin());
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
*/
class PluginRegistry {
constructor() {
/** @type {Map<string, SourcePlugin>} */
this._plugins = new Map();
}
/**
* Registriert ein Plugin.
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
*
* @param {SourcePlugin} plugin
*/
register(plugin) {
if (!(plugin instanceof SourcePlugin)) {
throw new Error(
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
);
}
const id = plugin.id;
if (!id || typeof id !== 'string') {
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
}
if (this._plugins.has(id)) {
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
}
this._plugins.set(id, plugin);
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
}
/**
* Findet das passende Plugin für eine erkannte Disc.
*
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
* dessen detect() true zurückgibt, wird verwendet.
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
*/
findPlugin(discInfo) {
const sorted = [...this._plugins.values()]
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
for (const plugin of sorted) {
try {
if (plugin.detect(discInfo)) {
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
return plugin;
}
} catch (error) {
logger.warn('registry:detect-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
return null;
}
/**
* Gibt ein Plugin anhand seiner ID zurück.
*
* @param {string} id
* @returns {SourcePlugin|null}
*/
getPlugin(id) {
return this._plugins.get(id) ?? null;
}
/**
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
*
* @returns {SourcePlugin[]}
*/
getAllPlugins() {
return [...this._plugins.values()];
}
/**
* Aggregiert die Settings-Schemata aller registrierten Plugins.
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
*
* @returns {Array<object>}
*/
getSettingsSchemas() {
const schemas = [];
for (const plugin of this._plugins.values()) {
try {
const pluginSchemas = plugin.getSettingsSchema();
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
schemas.push(...pluginSchemas);
}
} catch (error) {
logger.warn('registry:schema-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
return schemas;
}
/**
* Gibt die Anzahl der registrierten Plugins zurück.
* @returns {number}
*/
get size() {
return this._plugins.size;
}
}
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
const registry = new PluginRegistry();
module.exports = { PluginRegistry, registry };
+471
View File
@@ -0,0 +1,471 @@
'use strict';
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');
/**
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
*
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
* rip() → makemkvcon backup/mkv
* encode() → HandBrakeCLI
*
* Subklassen müssen implementieren:
* get id() → 'bluray' | 'dvd'
* get name() → Anzeigename
* get mediaProfile() → 'bluray' | 'dvd'
* detect(discInfo) → boolean
*
* ctx.extra-Felder für rip():
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* ctx.extra-Felder für encode():
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* ctx.extra-Felder für review():
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
*/
class VideoDiscPlugin extends SourcePlugin {
/**
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
* Muss von Subklassen implementiert werden.
* @returns {'bluray'|'dvd'}
*/
get mediaProfile() {
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
}
/**
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück.
*
* @param {string} devicePath
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
* @param {PluginContext} ctx
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const discInfo = ctx.extra?.discInfo || {};
const detectedTitle = String(
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
).trim();
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
jobId: job?.id,
error: error?.message || String(error)
});
return [];
});
ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id,
detectedTitle,
omdbCandidates: omdbCandidates.length
});
return { detectedTitle, omdbCandidates };
}
/**
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
*
* ctx.extra.reviewMode:
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
*/
async review(job, ctx) {
ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
const deviceInfo = ctx.extra?.deviceInfo || null;
const rawJobDir = ctx.extra?.rawJobDir || null;
ctx.logger.info(`${this.id}:review:start`, {
jobId: job?.id,
reviewMode,
deviceInfo: deviceInfo?.path || null,
rawJobDir: rawJobDir || null
});
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
let scanConfig;
if (reviewMode === 'rip' && rawJobDir) {
// Post-Rip scan: scan from the ripped RAW folder
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
mediaProfile: this.mediaProfile,
settingsMap: settings
});
} else {
// Pre-Rip scan: scan directly from disc device
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
mediaProfile: this.mediaProfile,
settingsMap: settings
});
}
ctx.logger.info(`${this.id}:review:scan-command`, {
jobId: job?.id,
cmd: scanConfig.cmd,
args: scanConfig.args
});
const scanLines = [];
const runInfo = await _runCommandCaptured({
cmd: scanConfig.cmd,
args: scanConfig.args,
onStdoutLine: (line) => scanLines.push(line),
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
});
ctx.logger.info(`${this.id}:review:scan-done`, {
jobId: job?.id,
exitCode: runInfo.exitCode,
lineCount: scanLines.length
});
// Return raw scan data — the Orchestrator (or legacy pipelineService)
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
return {
scanLines,
runInfo,
reviewMode,
mediaProfile: this.mediaProfile,
sourceArg: scanConfig.sourceArg || null
};
}
/**
* Rippt die Disc mit makemkvcon in den rawJobDir.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const {
rawJobDir,
deviceInfo,
selectedTitleId = null,
backupOutputBase = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!rawJobDir) {
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
}
if (!deviceInfo) {
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
}
ensureDir(rawJobDir);
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
selectedTitleId,
mediaProfile: this.mediaProfile,
settingsMap: settings,
backupOutputBase
});
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
ctx.logger.info(`${this.id}:rip:start`, {
jobId: job?.id,
cmd: ripConfig.cmd,
args: ripConfig.args,
ripMode,
rawJobDir,
selectedTitleId
});
ctx.emitProgress(0, ripMode === 'backup'
? `${this.name}: Backup läuft …`
: `${this.name}: Ripping läuft …`
);
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
let runInfo;
if (runCommandFn) {
runInfo = await runCommandFn({
jobId: job?.id,
stage: 'RIPPING',
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
});
} else {
runInfo = await _spawnAndWait({
cmd: ripConfig.cmd,
args: ripConfig.args,
cwd: rawJobDir,
jobId: job?.id,
progressParser: parseMakeMkvProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
});
}
ctx.logger.info(`${this.id}:rip:done`, {
jobId: job?.id,
rawJobDir,
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
});
ctx.extra.ripRunInfo = runInfo;
return runInfo;
}
/**
* Encodiert die gerippten Dateien mit HandBrakeCLI.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const {
inputPath,
outputPath,
encodePlan = {},
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
}
if (!outputPath) {
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
}
ensureDir(path.dirname(outputPath));
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
trackSelection: encodePlan.trackSelection || null,
titleId: encodePlan.handBrakeTitleId || null,
mediaProfile: this.mediaProfile,
settingsMap: settings,
userPreset: encodePlan.userPreset || null
});
ctx.logger.info(`${this.id}:encode:start`, {
jobId: job?.id,
cmd: handBrakeConfig.cmd,
args: handBrakeConfig.args,
titleId: encodePlan.handBrakeTitleId || null
});
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
const runInfo = await _spawnAndWait({
cmd: handBrakeConfig.cmd,
args: handBrakeConfig.args,
jobId: job?.id,
progressParser: parseHandBrakeProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
});
ctx.logger.info(`${this.id}:encode:done`, {
jobId: job?.id,
outputPath,
exitCode: runInfo.exitCode
});
ctx.extra.encodeRunInfo = runInfo;
return runInfo;
}
/**
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
*/
getSettingsSchema() {
const profile = this.mediaProfile;
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
const orderBase = profile === 'bluray' ? 200 : 220;
return [
{
key: `handbrake_preset_${profile}`,
category: 'Tools',
label: `HandBrake Preset (${label})`,
type: 'string',
required: 0,
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
default_value: null,
options_json: '[]',
validation_json: '{}',
order_index: orderBase
},
{
key: `output_extension_${profile}`,
category: 'Tools',
label: `Ausgabe-Format (${label})`,
type: 'select',
required: 1,
description: `Dateiendung des encodierten ${label}-Outputs.`,
default_value: 'mkv',
options_json: '["mkv","mp4"]',
validation_json: '{}',
order_index: orderBase + 1
},
{
key: `output_template_${profile}`,
category: 'Pfade',
label: `Output Template (${label})`,
type: 'string',
required: 1,
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
default_value: '{title} ({year})/{title} ({year})',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 700 + (profile === 'bluray' ? 0 : 5)
}
];
}
}
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
function _assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
throw error;
}
}
async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
const lines = [];
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: (line) => {
lines.push(line);
if (typeof onStdoutLine === 'function') {
onStdoutLine(line);
}
},
onStderrLine: () => {},
context: context || {}
});
try {
await handle.promise;
return { exitCode: 0, lines };
} catch (error) {
return {
exitCode: typeof error?.code === 'number' ? error.code : 1,
lines,
error: error?.message || String(error)
};
}
}
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
_assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.statusText || null);
}
}
},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.statusText || null);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { VideoDiscPlugin };
+12 -1
View File
@@ -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 = [];
+12 -1
View File
@@ -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
}
+38 -3
View File
@@ -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;
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) {
File diff suppressed because it is too large Load Diff
+614 -39
View File
@@ -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,6 +10489,19 @@ class PipelineService extends EventEmitter {
});
}
try {
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',
@@ -10015,6 +10510,7 @@ class PipelineService extends EventEmitter {
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',
+63 -7
View File
@@ -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 });
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
}
}
}
}
+20
View File
@@ -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');
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.11.0-5",
"version": "0.12.0",
"private": true,
"type": "module",
"scripts": {
+10 -7
View File
@@ -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}
/>
>
<Outlet />
</DashboardPage>
}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="/database" element={<DatabasePage />} />
>
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
</Route>
</Routes>
</main>
</div>
+140 -13
View File
@@ -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 (
<small className="track-action-note">
<span className={meta.className} title={meta.title}>
<span>{meta.label}</span>
<i className={`pi ${meta.icon}`} aria-hidden="true" />
</span>
</small>
);
}
function PathField({
label,
value,
@@ -528,17 +583,18 @@ export default function JobDetailDialog({
downloadBusyTarget = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
const showFinalLog = !running;
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running
&& (isCd || mkDone));
const canResumeReady = Boolean(
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
&& !running
&& typeof onResumeReady === 'function'
);
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const hasConfirmedPlan = Boolean(
job?.encodePlan
&& Array.isArray(job?.encodePlan?.titles)
@@ -578,6 +634,7 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const pluginExecution = resolveJobPluginExecution(job, null);
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -776,6 +833,12 @@ export default function JobDetailDialog({
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
</span>
</span>
{pluginExecution ? (
<>
<span className="job-infos-sep">|</span>
<Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} />
</>
) : null}
<span className="job-infos-sep">|</span>
{isCd ? (
<>
@@ -949,22 +1012,23 @@ export default function JobDetailDialog({
: '-';
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
const encodeResult = encodeResultMap.get(pos);
const encodeLabel = !selected ? 'Nicht übernommen'
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
const encodeClass = !selected ? ''
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
const outcome = !selected
? null
: encodeResult
? (encodeResult.success ? 'success' : 'error')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
return (
<div key={pos} className="track-item">
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
<SelectionStateNote selected={selected} outcome={outcome} />
</div>
);
})}
</div>
);
})() : (() => {
const audiobookFormat = String(job?.handbrakeInfo?.format || job?.encodePlan?.format || '').trim().toLowerCase();
const isSplitAudiobook = Boolean(audiobookFormat) && audiobookFormat !== 'm4b';
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
? job.handbrakeInfo.metadata.chapters
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
@@ -988,6 +1052,15 @@ export default function JobDetailDialog({
: '-';
const step = stepsByIndex.get(id);
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
const outcome = stepStatus === 'SUCCESS'
? 'success'
: stepStatus === 'ERROR'
? 'error'
: stepStatus === 'CANCELLED'
? 'cancelled'
: (job?.encodeSuccess != null
? (job.encodeSuccess ? 'success' : (String(job?.status || '').trim().toUpperCase() === 'CANCELLED' ? 'cancelled' : 'error'))
: null);
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
: stepStatus === 'ERROR' ? 'Fehler'
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
@@ -999,7 +1072,11 @@ export default function JobDetailDialog({
return (
<div key={id} className="track-item">
<span>#{id} | {label} | {durLabel}</span>
{isSplitAudiobook ? (
<SelectionStateNote selected outcome={outcome} />
) : (
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
)}
</div>
);
})}
@@ -1076,7 +1153,38 @@ export default function JobDetailDialog({
</div>
) : (
<div className="actions-section">
{!isCd ? (
{isCd ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode || typeof onReencode !== 'function'}
/>
<span className="action-desc">Encodiert die vorhandenen WAV-Rohdaten erneut ohne die CD neu zu lesen. Nützlich wenn sich Format oder Metadaten geändert haben.</span>
</div>
{typeof onAssignCdMetadata === 'function' ? (
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
) : null}
</div>
) : (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
@@ -1141,6 +1249,25 @@ export default function JobDetailDialog({
}</span>
</div>
</div>
)}
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
<Button
label="OMDB neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
</div>
</div>
) : null}
<div className="actions-group">
+26 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { Toast } from 'primereact/toast';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
@@ -20,10 +20,12 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { resolveJobPluginExecution } from '../utils/pluginExecution';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const dashboardStatuses = new Set([
'ANALYZING',
'METADATA_SELECTION',
@@ -822,8 +824,10 @@ export default function DashboardPage({
jobsRefreshToken,
pendingExpandedJobId,
onPendingExpandedJobHandled,
downloadSummary = null
downloadSummary = null,
children = null
}) {
const location = useLocation();
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
@@ -955,6 +959,7 @@ export default function DashboardPage({
: audiobookUploadPhase === 'error'
? 'Fehler'
: 'Inaktiv';
const isSubpageRoute = location.pathname !== '/';
const loadDashboardJobs = async () => {
setJobsLoading(true);
@@ -1335,19 +1340,12 @@ export default function DashboardPage({
}
};
const refreshKnownDrives = () => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
};
const handleRescan = async () => {
setBusy(true);
try {
const response = await api.rescanDisc();
refreshKnownDrives();
const allDetected = response?.result?.allDetected || [];
const emitted = response?.result?.emitted || 'none';
const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
const count = allDetected.length;
toastRef.current?.show({
severity: count > 0 ? 'success' : 'info',
@@ -1357,7 +1355,6 @@ export default function DashboardPage({
: 'Kein Medium erkannt.',
life: 2800
});
void emitted; // used implicitly via count
await refreshPipeline();
await loadDashboardJobs();
} catch (error) {
@@ -1367,6 +1364,12 @@ export default function DashboardPage({
}
};
const refreshKnownDrives = () => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
};
const handleRescanDrive = async (devicePath) => {
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
try {
@@ -2029,7 +2032,6 @@ export default function DashboardPage({
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
const contextDevice = pipeline?.context?.device;
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
const allDrives = useMemo(() => {
@@ -2360,6 +2362,7 @@ export default function DashboardPage({
const driveJobId = normalizeJobId(cdDrive?.jobId);
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
return (
<div key={drivePath} className="drive-list-item">
@@ -2380,7 +2383,7 @@ export default function DashboardPage({
size="small"
severity="secondary"
loading={isRescanBusy}
disabled={isRescanBusy || isDriveActive}
disabled={isRescanBusy || isDriveActive || isCdDriveLocked}
onClick={() => handleRescanDrive(drivePath)}
title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen"
@@ -2481,6 +2484,11 @@ export default function DashboardPage({
</div>
<div className="dashboard-col dashboard-col-center">
{isSubpageRoute ? (
<div className="dashboard-subpage-content">
{children}
</div>
) : (
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading ? (
<p>Jobs werden geladen ...</p>
@@ -2530,6 +2538,7 @@ export default function DashboardPage({
? pipelineForJob.context.selectedMetadata
: {};
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context);
if (isExpanded) {
return (
@@ -2552,6 +2561,7 @@ export default function DashboardPage({
</strong>
<div className="dashboard-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2681,6 +2691,7 @@ export default function DashboardPage({
</div>
<div className="dashboard-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2700,6 +2711,7 @@ export default function DashboardPage({
</div>
)}
</Card>
)}
</div>
<div className="dashboard-col dashboard-col-right">
@@ -3051,7 +3063,7 @@ export default function DashboardPage({
</div>
</div>
{monitoringState.enabled ? (
{!isSubpageRoute && monitoringState.enabled ? (
<Card title="Hardware Monitoring">
<div className="hardware-monitor-head">
{!monitoringState.enabled ? (
+2 -21
View File
@@ -48,7 +48,7 @@ export default function DatabasePage() {
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
if (!confirmed) {
return;
}
@@ -57,31 +57,12 @@ export default function DatabasePage() {
try {
const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
if (newJobId) {
try {
await api.reencodeJob(newJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt & Scan gestartet',
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
life: 4000
});
} catch (scanError) {
toastRef.current?.show({
severity: 'info',
summary: 'Job angelegt',
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
life: 6000
});
}
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt',
detail: `Historieneintrag wurde erstellt.`,
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
life: 3500
});
}
await loadOrphans();
} catch (error) {
toastRef.current?.show({
+135
View File
@@ -10,6 +10,8 @@ import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
@@ -371,6 +373,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
@@ -735,6 +743,104 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAssignOmdb = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
title: row?.title || row?.detected_title || '',
year: row?.year || null,
imdbId: row?.imdb_id || null,
poster: row?.poster_url || null
},
omdbCandidates: []
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
return [];
}
};
const handleOmdbSubmit = async (payload) => {
setOmdbAssignBusy(true);
try {
await api.assignJobOmdb(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
setOmdbAssignBusy(false);
}
};
const handleAssignCdMetadata = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const tracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
? makemkvInfo.tracks
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
tracks,
selectedMetadata: makemkvInfo?.selectedMetadata || {}
};
setCdMetadataDialogContext(context);
setCdMetadataDialogVisible(true);
};
const handleMusicBrainzSearch = async (query) => {
try {
const response = await api.searchMusicBrainz(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
return [];
}
};
const handleMusicBrainzReleaseFetch = async (mbId) => {
try {
const response = await api.getMusicBrainzRelease(mbId);
return response?.release || null;
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Abruf fehlgeschlagen', detail: error.message });
return null;
}
};
const handleCdMetadataSubmit = async (payload) => {
setCdMetadataAssignBusy(true);
try {
await api.assignJobCdMetadata(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'MusicBrainz-Metadaten wurden aktualisiert.', life: 3000 });
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Metadaten-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
setCdMetadataAssignBusy(false);
}
};
const closeDeleteEntryDialog = () => {
if (deleteEntryTargetBusy) {
return;
@@ -1177,12 +1283,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRestartReview={handleRestartReview}
onReencode={handleReencode}
onRetry={handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
@@ -1324,6 +1434,31 @@ export default function HistoryPage({ refreshToken = 0 }) {
/>
</div>
</Dialog>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext || {}}
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
busy={omdbAssignBusy}
/>
<CdMetadataDialog
visible={cdMetadataDialogVisible}
context={cdMetadataDialogContext || {}}
onHide={() => {
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
}}
onSubmit={handleCdMetadataSubmit}
onSearch={handleMusicBrainzSearch}
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={cdMetadataAssignBusy}
/>
</div>
);
}
+35 -5
View File
@@ -13,6 +13,7 @@
--rip-muted: #6a4d38;
--rip-panel: #fffaf1;
--rip-panel-soft: #fdf5e7;
--dashboard-side-width: 20rem;
/* PrimeReact theme tokens */
--primary-color: var(--rip-brown-600);
@@ -238,13 +239,16 @@ body {
}
.app-main {
width: min(1280px, 96vw);
margin: 1rem auto 2rem;
width: 100%;
max-width: none;
margin: 1rem 0 2rem;
padding: 0 1rem;
}
.app-upload-banner {
width: min(1280px, 96vw);
margin: 0.9rem auto 0;
width: auto;
max-width: none;
margin: 0.9rem 1rem 0;
padding: 0.8rem 0.95rem;
border: 1px solid var(--rip-border);
border-radius: 0.7rem;
@@ -316,7 +320,8 @@ body {
.dashboard-3col-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
width: 100%;
grid-template-columns: var(--dashboard-side-width) minmax(0, 1fr) var(--dashboard-side-width);
gap: 1rem;
align-items: start;
}
@@ -328,6 +333,18 @@ body {
min-width: 0;
}
.dashboard-subpage-content {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.dashboard-subpage-content > * {
min-width: 0;
}
@media (max-width: 1100px) {
.dashboard-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
@@ -3295,6 +3312,19 @@ body {
color: var(--rip-muted);
}
.track-selection-inline-neutral {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: var(--rip-muted);
font-weight: 600;
font-size: 0.8rem;
}
.track-selection-inline-neutral .pi {
font-size: 0.85rem;
}
.spurauswahl-block {
display: grid;
gap: 0.5rem;
+77
View File
@@ -0,0 +1,77 @@
function normalizeStage(value) {
const stage = String(value || '').trim().toLowerCase();
return stage || null;
}
const STAGE_LABELS = {
analyze: 'Analyse',
rip: 'Rip',
review: 'Review',
encode: 'Encode',
finalize: 'Finalize',
oncancel: 'Cancel',
onretry: 'Retry'
};
export function normalizePluginExecutionState(rawState) {
if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) {
return null;
}
const markerSource = String(rawState.markerSource || '').trim().toLowerCase();
const pluginFile = String(rawState.pluginFile || '').trim();
if (markerSource !== 'plugin-file' || !pluginFile) {
return null;
}
const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || null;
const pluginName = String(rawState.pluginName || '').trim() || pluginId || 'Plugin';
const explicitStages = Array.isArray(rawState.stages)
? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean)
: [];
const byStage = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage)
? rawState.byStage
: {};
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage).map((stage) => normalizeStage(stage)).filter(Boolean),
...[normalizeStage(rawState.lastStage)].filter(Boolean)
]));
const stageLabels = stages.map((stage) => STAGE_LABELS[stage] || stage);
const titleParts = [
`Plugin-Code aktiv: ${pluginName}`,
pluginId ? `ID: ${pluginId}` : null,
`Datei: ${pluginFile}`,
stageLabels.length > 0 ? `Phasen: ${stageLabels.join(', ')}` : null
].filter(Boolean);
return {
markerSource,
pluginId,
pluginName,
pluginFile,
stages,
stageLabels,
label: `Beta: ${pluginName}`,
title: titleParts.join(' | ')
};
}
export function resolveJobPluginExecution(job = null, liveContext = null) {
const liveExecution = normalizePluginExecutionState(liveContext?.pluginExecution);
if (liveExecution) {
return liveExecution;
}
const makemkvExecution = normalizePluginExecutionState(job?.makemkvInfo?.pluginExecution);
if (makemkvExecution) {
return makemkvExecution;
}
const handbrakeExecution = normalizePluginExecutionState(job?.handbrakeInfo?.pluginExecution);
if (handbrakeExecution) {
return handbrakeExecution;
}
return normalizePluginExecutionState(job?.pluginExecution);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.11.0-5",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.11.0-5",
"version": "0.12.0",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.11.0-5",
"version": "0.12.0",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",