0.12.0 Begin neu Architecture
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"watch": [
|
||||||
|
"src",
|
||||||
|
".env"
|
||||||
|
],
|
||||||
|
"ext": "js,json,env",
|
||||||
|
"ignore": [
|
||||||
|
"data/**",
|
||||||
|
"logs/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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')`);
|
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(`
|
await db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -406,7 +406,8 @@ async function ripAndEncode(options) {
|
|||||||
onLog,
|
onLog,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
context
|
context,
|
||||||
|
skipRip = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!SUPPORTED_FORMATS.has(format)) {
|
if (!SUPPORTED_FORMATS.has(format)) {
|
||||||
@@ -437,6 +438,15 @@ async function ripAndEncode(options) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
// ── 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++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
assertNotCancelled(isCancelled);
|
assertNotCancelled(isCancelled);
|
||||||
const track = tracksToRip[i];
|
const track = tracksToRip[i];
|
||||||
@@ -501,6 +511,7 @@ async function ripAndEncode(options) {
|
|||||||
|
|
||||||
log('info', `Track ${track.position} gerippt.`);
|
log('info', `Track ${track.position} gerippt.`);
|
||||||
}
|
}
|
||||||
|
} // end if (!skipRip)
|
||||||
|
|
||||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
const encodeResults = [];
|
const encodeResults = [];
|
||||||
|
|||||||
@@ -759,15 +759,17 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
|
|
||||||
async checkMediaPresent(devicePath) {
|
async checkMediaPresent(devicePath) {
|
||||||
let blkidType = null;
|
let blkidType = null;
|
||||||
|
let blkidError = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
blkidError = String(_error?.message || _error || 'unknown');
|
||||||
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||||
}
|
}
|
||||||
|
logger.info('check-media:blkid', { devicePath, blkidType, blkidError });
|
||||||
|
|
||||||
if (blkidType) {
|
if (blkidType) {
|
||||||
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
|
||||||
return { 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 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 hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||||
|
logger.info('check-media:udevadm', { devicePath, hasBD, hasDVD, hasCD });
|
||||||
if (hasCD && !hasDVD && !hasBD) {
|
if (hasCD && !hasDVD && !hasBD) {
|
||||||
logger.debug('udevadm:audio-cd', { devicePath });
|
logger.debug('udevadm:audio-cd', { devicePath });
|
||||||
return { hasMedia: true, type: 'audio_cd' };
|
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) {
|
} catch (_udevError) {
|
||||||
// udevadm not available or failed – ignore
|
// udevadm not available or failed – ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
const pendingResumeIds = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||||
@@ -136,11 +137,34 @@ class DownloadService {
|
|||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (item.status === 'queued' || item.status === 'processing') {
|
if (item.status === 'queued' || item.status === 'processing') {
|
||||||
item.status = 'failed';
|
const archiveExists = await this._pathExists(item.archivePath);
|
||||||
item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.';
|
if (archiveExists) {
|
||||||
item.finishedAt = nowIso;
|
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;
|
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);
|
await this._safeUnlink(item.partialPath);
|
||||||
|
logger.warn('download:init:requeue-interrupted-job', {
|
||||||
|
id: item.id,
|
||||||
|
archiveName: item.archiveName
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (item.status === 'ready') {
|
} else if (item.status === 'ready') {
|
||||||
const exists = await this._pathExists(item.archivePath);
|
const exists = await this._pathExists(item.archivePath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -157,6 +181,17 @@ class DownloadService {
|
|||||||
await this._persistItem(item);
|
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) {
|
_normalizeLoadedItem(rawItem, fallbackDir) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3857,6 +3857,8 @@ class PipelineService extends EventEmitter {
|
|||||||
this.queueEntries = [];
|
this.queueEntries = [];
|
||||||
this.queuePumpRunning = false;
|
this.queuePumpRunning = false;
|
||||||
this.queueEntrySeq = 1;
|
this.queueEntrySeq = 1;
|
||||||
|
this.pluginRegistryInitialized = false;
|
||||||
|
this.sourcePluginRegistry = null;
|
||||||
this.lastQueueSnapshot = {
|
this.lastQueueSnapshot = {
|
||||||
maxParallelJobs: 1,
|
maxParallelJobs: 1,
|
||||||
runningCount: 0,
|
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) {
|
isRipSuccessful(job = null) {
|
||||||
if (Number(job?.rip_successful || 0) === 1) {
|
if (Number(job?.rip_successful || 0) === 1) {
|
||||||
return true;
|
return true;
|
||||||
@@ -4402,6 +4716,34 @@ class PipelineService extends EventEmitter {
|
|||||||
return null;
|
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) {
|
normalizeParallelJobsLimit(rawValue) {
|
||||||
const value = Number(rawValue);
|
const value = Number(rawValue);
|
||||||
if (!Number.isFinite(value) || value < 1) {
|
if (!Number.isFinite(value) || value < 1) {
|
||||||
@@ -6051,10 +6393,13 @@ class PipelineService extends EventEmitter {
|
|||||||
...device,
|
...device,
|
||||||
mediaProfile
|
mediaProfile
|
||||||
};
|
};
|
||||||
|
const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile);
|
||||||
|
|
||||||
// Route audio CDs to the dedicated CD pipeline
|
// Route audio CDs to the dedicated CD pipeline
|
||||||
if (mediaProfile === 'cd') {
|
if (mediaProfile === 'cd') {
|
||||||
return this.analyzeCd(deviceWithProfile);
|
return this.analyzeCd(deviceWithProfile, {
|
||||||
|
plugin: analyzePlugin?.id === 'cd' ? analyzePlugin : null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await historyService.createJob({
|
const job = await historyService.createJob({
|
||||||
@@ -6064,18 +6409,50 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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', {
|
logger.info('metadata:prepare:result', {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
detectedTitle,
|
detectedTitle: effectiveDetectedTitle,
|
||||||
omdbCandidateCount: omdbCandidates.length
|
omdbCandidateCount: omdbCandidates.length
|
||||||
});
|
});
|
||||||
|
|
||||||
await historyService.updateJob(job.id, {
|
const prepareInfo = this.withPluginExecutionMeta(
|
||||||
status: 'METADATA_SELECTION',
|
this.withAnalyzeContextMediaProfile({
|
||||||
last_state: 'METADATA_SELECTION',
|
|
||||||
detected_title: detectedTitle,
|
|
||||||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
|
||||||
phase: 'PREPARE',
|
phase: 'PREPARE',
|
||||||
preparedAt: nowIso(),
|
preparedAt: nowIso(),
|
||||||
analyzeContext: {
|
analyzeContext: {
|
||||||
@@ -6084,12 +6461,20 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedPlaylist: null,
|
selectedPlaylist: null,
|
||||||
selectedTitleId: 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(
|
await historyService.appendLog(
|
||||||
job.id,
|
job.id,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
`Disk erkannt. Metadaten-Suche vorbereitet mit Query "${detectedTitle}".`
|
`Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
|
||||||
);
|
);
|
||||||
|
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const runningJobs = await historyService.getRunningJobs();
|
||||||
@@ -6104,15 +6489,18 @@ class PipelineService extends EventEmitter {
|
|||||||
context: {
|
context: {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
device: deviceWithProfile,
|
device: deviceWithProfile,
|
||||||
detectedTitle,
|
detectedTitle: effectiveDetectedTitle,
|
||||||
detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback',
|
detectedTitleSource: effectiveDetectedTitle !== detectedTitle
|
||||||
|
? 'plugin'
|
||||||
|
: (device.discLabel ? 'discLabel' : 'fallback'),
|
||||||
omdbCandidates,
|
omdbCandidates,
|
||||||
mediaProfile,
|
mediaProfile,
|
||||||
playlistAnalysis: null,
|
playlistAnalysis: null,
|
||||||
playlistDecisionRequired: false,
|
playlistDecisionRequired: false,
|
||||||
playlistCandidates: [],
|
playlistCandidates: [],
|
||||||
selectedPlaylist: null,
|
selectedPlaylist: null,
|
||||||
selectedTitleId: null
|
selectedTitleId: null,
|
||||||
|
...(pluginExecution ? { pluginExecution } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -6125,12 +6513,12 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
void this.notifyPushover('metadata_ready', {
|
void this.notifyPushover('metadata_ready', {
|
||||||
title: 'Ripster - Metadaten bereit',
|
title: 'Ripster - Metadaten bereit',
|
||||||
message: `Job #${job.id}: ${detectedTitle} (${omdbCandidates.length} Treffer)`
|
message: `Job #${job.id}: ${effectiveDetectedTitle} (${omdbCandidates.length} Treffer)`
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
detectedTitle,
|
detectedTitle: effectiveDetectedTitle,
|
||||||
omdbCandidates
|
omdbCandidates
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -8107,6 +8495,98 @@ class PipelineService extends EventEmitter {
|
|||||||
return this.startPreparedJob(sourceJobId);
|
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);
|
const ripSuccessful = this.isRipSuccessful(sourceJob);
|
||||||
if (!ripSuccessful) {
|
if (!ripSuccessful) {
|
||||||
const error = new Error(
|
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.`
|
`${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) {
|
if (devicePath) {
|
||||||
diskDetectionService.lockDevice(devicePath, {
|
diskDetectionService.lockDevice(devicePath, {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -10007,6 +10489,19 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
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({
|
makemkvInfo = await this.runCommand({
|
||||||
jobId,
|
jobId,
|
||||||
stage: 'RIPPING',
|
stage: 'RIPPING',
|
||||||
@@ -10015,6 +10510,7 @@ class PipelineService extends EventEmitter {
|
|||||||
args: ripConfig.args,
|
args: ripConfig.args,
|
||||||
parser: parseMakeMkvProgress
|
parser: parseMakeMkvProgress
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (devicePath) {
|
if (devicePath) {
|
||||||
diskDetectionService.unlockDevice(devicePath, {
|
diskDetectionService.unlockDevice(devicePath, {
|
||||||
@@ -10039,10 +10535,18 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json);
|
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, {
|
await historyService.updateJob(jobId, {
|
||||||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
||||||
...makemkvInfo,
|
...makemkvInfo,
|
||||||
analyzeContext: mkInfoBeforeRip?.analyzeContext || null
|
analyzeContext: mkInfoBeforeRip?.analyzeContext || null,
|
||||||
|
pluginExecution: postRipPluginExecution || null
|
||||||
}, mediaProfile)),
|
}, mediaProfile)),
|
||||||
rip_successful: 1
|
rip_successful: 1
|
||||||
});
|
});
|
||||||
@@ -10177,15 +10681,6 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const sourceStatus = String(sourceJob.status || '').trim().toUpperCase();
|
const sourceStatus = String(sourceJob.status || '').trim().toUpperCase();
|
||||||
const sourceLastState = String(sourceJob.last_state || '').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 sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||||||
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
||||||
@@ -10196,6 +10691,18 @@ class PipelineService extends EventEmitter {
|
|||||||
const isCdRetry = mediaProfile === 'cd';
|
const isCdRetry = mediaProfile === 'cd';
|
||||||
const isAudiobookRetry = mediaProfile === 'audiobook';
|
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;
|
let cdRetryConfig = null;
|
||||||
if (isCdRetry) {
|
if (isCdRetry) {
|
||||||
const normalizeTrackPosition = (value) => {
|
const normalizeTrackPosition = (value) => {
|
||||||
@@ -10208,11 +10715,28 @@ class PipelineService extends EventEmitter {
|
|||||||
const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks)
|
const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks)
|
||||||
? sourceMakemkvInfo.tracks
|
? sourceMakemkvInfo.tracks
|
||||||
: (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []);
|
: (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []);
|
||||||
|
// Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen)
|
||||||
if (sourceTracks.length === 0) {
|
if (sourceTracks.length === 0) {
|
||||||
const error = new Error('Retry nicht möglich: keine CD-Trackdaten im Quelljob vorhanden.');
|
cdRetryConfig = {
|
||||||
error.statusCode = 400;
|
format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac',
|
||||||
throw error;
|
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(
|
const selectedTracks = normalizeCdTrackPositionList(
|
||||||
Array.isArray(sourceEncodePlan?.selectedTracks)
|
Array.isArray(sourceEncodePlan?.selectedTracks)
|
||||||
? sourceEncodePlan.selectedTracks
|
? sourceEncodePlan.selectedTracks
|
||||||
@@ -10252,6 +10776,7 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
|
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
|
||||||
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
||||||
};
|
};
|
||||||
|
} // end else (sourceTracks.length > 0)
|
||||||
} else if (!isAudiobookRetry) {
|
} else if (!isAudiobookRetry) {
|
||||||
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
||||||
@@ -11570,6 +12095,9 @@ class PipelineService extends EventEmitter {
|
|||||||
statusText: message,
|
statusText: message,
|
||||||
context: failContext
|
context: failContext
|
||||||
});
|
});
|
||||||
|
if (isCancelled) {
|
||||||
|
this._releaseCdDrive(cdDevicePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.setState(finalState, {
|
await this.setState(finalState, {
|
||||||
@@ -12483,7 +13011,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async analyzeCd(device) {
|
async analyzeCd(device, options = {}) {
|
||||||
const devicePath = String(device?.path || '').trim();
|
const devicePath = String(device?.path || '').trim();
|
||||||
const detectedTitle = String(
|
const detectedTitle = String(
|
||||||
device?.discLabel || device?.label || device?.model || 'Audio CD'
|
device?.discLabel || device?.label || device?.model || 'Audio CD'
|
||||||
@@ -12499,7 +13027,8 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await settingsService.getSettingsMap();
|
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
|
// Read TOC
|
||||||
this._setCdDriveState(devicePath, {
|
this._setCdDriveState(devicePath, {
|
||||||
@@ -12512,7 +13041,48 @@ class PipelineService extends EventEmitter {
|
|||||||
context: { jobId: job.id, device, mediaProfile: 'cd' }
|
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 });
|
logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length });
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
|
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(),
|
preparedAt: nowIso(),
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
tracks,
|
tracks,
|
||||||
detectedTitle
|
detectedTitle: effectiveDetectedTitle
|
||||||
};
|
};
|
||||||
|
const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution);
|
||||||
|
|
||||||
await historyService.updateJob(job.id, {
|
await historyService.updateJob(job.id, {
|
||||||
status: 'CD_METADATA_SELECTION',
|
status: 'CD_METADATA_SELECTION',
|
||||||
last_state: 'CD_METADATA_SELECTION',
|
last_state: 'CD_METADATA_SELECTION',
|
||||||
detected_title: detectedTitle,
|
detected_title: effectiveDetectedTitle,
|
||||||
makemkv_info_json: JSON.stringify(cdInfo)
|
makemkv_info_json: JSON.stringify(persistedCdInfo)
|
||||||
});
|
});
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
job.id,
|
job.id,
|
||||||
@@ -12557,12 +13128,13 @@ class PipelineService extends EventEmitter {
|
|||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
cdparanoiaCommandPreview,
|
cdparanoiaCommandPreview,
|
||||||
detectedTitle,
|
detectedTitle: effectiveDetectedTitle,
|
||||||
tracks
|
tracks,
|
||||||
|
...(pluginExecution ? { pluginExecution } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { jobId: job.id, detectedTitle, tracks };
|
return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) });
|
logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) });
|
||||||
await this.failJob(job.id, 'CD_ANALYZING', error);
|
await this.failJob(job.id, 'CD_ANALYZING', error);
|
||||||
@@ -13197,7 +13769,8 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedTrackPositions,
|
selectedTrackPositions,
|
||||||
tocTracks,
|
tocTracks,
|
||||||
selectedMeta,
|
selectedMeta,
|
||||||
encodePlan = null
|
encodePlan = null,
|
||||||
|
skipRip = false
|
||||||
}) {
|
}) {
|
||||||
const processKey = Number(jobId);
|
const processKey = Number(jobId);
|
||||||
let currentProcessHandle = null;
|
let currentProcessHandle = null;
|
||||||
@@ -13307,6 +13880,7 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedTracks: selectedTrackPositions,
|
selectedTracks: selectedTrackPositions,
|
||||||
tracks: tocTracks,
|
tracks: tocTracks,
|
||||||
meta: selectedMeta,
|
meta: selectedMeta,
|
||||||
|
skipRip,
|
||||||
onProcessHandle: bindProcessHandle,
|
onProcessHandle: bindProcessHandle,
|
||||||
isCancelled: () => this.cancelRequestedByJob.has(processKey),
|
isCancelled: () => this.cancelRequestedByJob.has(processKey),
|
||||||
onProgress: async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => {
|
onProgress: async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => {
|
||||||
@@ -13490,6 +14064,7 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedMetadata: selectedMeta
|
selectedMetadata: selectedMeta
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this._releaseCdDrive(devicePath);
|
||||||
|
|
||||||
void this.notifyPushover('job_finished', {
|
void this.notifyPushover('job_finished', {
|
||||||
title: 'Ripster - CD Rip erfolgreich',
|
title: 'Ripster - CD Rip erfolgreich',
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ class WebSocketService {
|
|||||||
this.clients = new Set();
|
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) {
|
init(httpServer) {
|
||||||
if (this.wss) {
|
if (this.wss) {
|
||||||
return;
|
return;
|
||||||
@@ -18,21 +29,32 @@ class WebSocketService {
|
|||||||
this.clients.add(socket);
|
this.clients.add(socket);
|
||||||
logger.info('client:connected', { clients: this.clients.size });
|
logger.info('client:connected', { clients: this.clients.size });
|
||||||
|
|
||||||
|
try {
|
||||||
socket.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'WS_CONNECTED',
|
type: 'WS_CONNECTED',
|
||||||
payload: { connectedAt: new Date().toISOString() }
|
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', () => {
|
socket.on('close', () => {
|
||||||
this.clients.delete(socket);
|
this._removeClient(socket, 'info', 'client:closed');
|
||||||
logger.info('client:closed', { clients: this.clients.size });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', () => {
|
socket.on('error', (error) => {
|
||||||
this.clients.delete(socket);
|
logger.warn('client:error', {
|
||||||
logger.warn('client:error', { clients: this.clients.size });
|
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) {
|
for (const client of this.clients) {
|
||||||
if (client.readyState === client.OPEN) {
|
if (!client || client.readyState !== client.OPEN) {
|
||||||
client.send(message);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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_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)
|
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);
|
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');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+10
-7
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 { Button } from 'primereact/button';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
@@ -493,13 +493,16 @@ function App() {
|
|||||||
pendingExpandedJobId={pendingDashboardJobId}
|
pendingExpandedJobId={pendingDashboardJobId}
|
||||||
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
||||||
downloadSummary={downloadSummary}
|
downloadSummary={downloadSummary}
|
||||||
/>
|
>
|
||||||
|
<Outlet />
|
||||||
|
</DashboardPage>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||||
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||||
<Route path="/database" element={<DatabasePage />} />
|
<Route path="database" element={<DatabasePage />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
|
import { resolveJobPluginExecution } from '../utils/pluginExecution';
|
||||||
import { getStatusLabel } from '../utils/statusPresentation';
|
import { getStatusLabel } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const CD_FORMAT_LABELS = {
|
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({
|
function PathField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -528,17 +583,18 @@ export default function JobDetailDialog({
|
|||||||
downloadBusyTarget = null
|
downloadBusyTarget = null
|
||||||
}) {
|
}) {
|
||||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
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 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(
|
const canResumeReady = Boolean(
|
||||||
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
||||||
&& !running
|
&& !running
|
||||||
&& typeof onResumeReady === 'function'
|
&& typeof onResumeReady === 'function'
|
||||||
);
|
);
|
||||||
const mediaType = resolveMediaType(job);
|
|
||||||
const isCd = mediaType === 'cd';
|
|
||||||
const isAudiobook = mediaType === 'audiobook';
|
|
||||||
const hasConfirmedPlan = Boolean(
|
const hasConfirmedPlan = Boolean(
|
||||||
job?.encodePlan
|
job?.encodePlan
|
||||||
&& Array.isArray(job?.encodePlan?.titles)
|
&& Array.isArray(job?.encodePlan?.titles)
|
||||||
@@ -578,6 +634,7 @@ export default function JobDetailDialog({
|
|||||||
const mediaTypeAlt = mediaTypeLabel;
|
const mediaTypeAlt = mediaTypeLabel;
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
|
const pluginExecution = resolveJobPluginExecution(job, null);
|
||||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||||
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||||
|| configuredSelection.postScriptIds.length > 0
|
|| configuredSelection.postScriptIds.length > 0
|
||||||
@@ -776,6 +833,12 @@ export default function JobDetailDialog({
|
|||||||
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
|
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</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>
|
<span className="job-infos-sep">|</span>
|
||||||
{isCd ? (
|
{isCd ? (
|
||||||
<>
|
<>
|
||||||
@@ -949,22 +1012,23 @@ export default function JobDetailDialog({
|
|||||||
: '-';
|
: '-';
|
||||||
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
||||||
const encodeResult = encodeResultMap.get(pos);
|
const encodeResult = encodeResultMap.get(pos);
|
||||||
const encodeLabel = !selected ? 'Nicht übernommen'
|
const outcome = !selected
|
||||||
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
|
? null
|
||||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
|
: encodeResult
|
||||||
const encodeClass = !selected ? ''
|
? (encodeResult.success ? 'success' : 'error')
|
||||||
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
|
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
|
||||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
|
|
||||||
return (
|
return (
|
||||||
<div key={pos} className="track-item">
|
<div key={pos} className="track-item">
|
||||||
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
||||||
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
<SelectionStateNote selected={selected} outcome={outcome} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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
|
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
|
||||||
? job.handbrakeInfo.metadata.chapters
|
? job.handbrakeInfo.metadata.chapters
|
||||||
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
|
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
|
||||||
@@ -988,6 +1052,15 @@ export default function JobDetailDialog({
|
|||||||
: '-';
|
: '-';
|
||||||
const step = stepsByIndex.get(id);
|
const step = stepsByIndex.get(id);
|
||||||
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
|
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'
|
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
|
||||||
: stepStatus === 'ERROR' ? 'Fehler'
|
: stepStatus === 'ERROR' ? 'Fehler'
|
||||||
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
|
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
|
||||||
@@ -999,7 +1072,11 @@ export default function JobDetailDialog({
|
|||||||
return (
|
return (
|
||||||
<div key={id} className="track-item">
|
<div key={id} className="track-item">
|
||||||
<span>#{id} | {label} | {durLabel}</span>
|
<span>#{id} | {label} | {durLabel}</span>
|
||||||
|
{isSplitAudiobook ? (
|
||||||
|
<SelectionStateNote selected outcome={outcome} />
|
||||||
|
) : (
|
||||||
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1076,7 +1153,38 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actions-section">
|
<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">
|
||||||
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
||||||
{canResumeReady ? (
|
{canResumeReady ? (
|
||||||
@@ -1141,6 +1249,25 @@ export default function JobDetailDialog({
|
|||||||
}</span>
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
) : null}
|
||||||
|
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { Toast } from 'primereact/toast';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
@@ -20,10 +20,12 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
|||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||||
|
import { resolveJobPluginExecution } from '../utils/pluginExecution';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
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([
|
const dashboardStatuses = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
@@ -822,8 +824,10 @@ export default function DashboardPage({
|
|||||||
jobsRefreshToken,
|
jobsRefreshToken,
|
||||||
pendingExpandedJobId,
|
pendingExpandedJobId,
|
||||||
onPendingExpandedJobHandled,
|
onPendingExpandedJobHandled,
|
||||||
downloadSummary = null
|
downloadSummary = null,
|
||||||
|
children = null
|
||||||
}) {
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
||||||
@@ -955,6 +959,7 @@ export default function DashboardPage({
|
|||||||
: audiobookUploadPhase === 'error'
|
: audiobookUploadPhase === 'error'
|
||||||
? 'Fehler'
|
? 'Fehler'
|
||||||
: 'Inaktiv';
|
: 'Inaktiv';
|
||||||
|
const isSubpageRoute = location.pathname !== '/';
|
||||||
|
|
||||||
const loadDashboardJobs = async () => {
|
const loadDashboardJobs = async () => {
|
||||||
setJobsLoading(true);
|
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 () => {
|
const handleRescan = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.rescanDisc();
|
const response = await api.rescanDisc();
|
||||||
refreshKnownDrives();
|
refreshKnownDrives();
|
||||||
const allDetected = response?.result?.allDetected || [];
|
const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
|
||||||
const emitted = response?.result?.emitted || 'none';
|
|
||||||
const count = allDetected.length;
|
const count = allDetected.length;
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: count > 0 ? 'success' : 'info',
|
severity: count > 0 ? 'success' : 'info',
|
||||||
@@ -1357,7 +1355,6 @@ export default function DashboardPage({
|
|||||||
: 'Kein Medium erkannt.',
|
: 'Kein Medium erkannt.',
|
||||||
life: 2800
|
life: 2800
|
||||||
});
|
});
|
||||||
void emitted; // used implicitly via count
|
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
} catch (error) {
|
} 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) => {
|
const handleRescanDrive = async (devicePath) => {
|
||||||
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
|
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
|
||||||
try {
|
try {
|
||||||
@@ -2029,7 +2032,6 @@ export default function DashboardPage({
|
|||||||
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
||||||
const contextDevice = pipeline?.context?.device;
|
const contextDevice = pipeline?.context?.device;
|
||||||
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
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
|
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
|
||||||
const allDrives = useMemo(() => {
|
const allDrives = useMemo(() => {
|
||||||
@@ -2360,6 +2362,7 @@ export default function DashboardPage({
|
|||||||
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
||||||
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
||||||
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
||||||
|
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={drivePath} className="drive-list-item">
|
<div key={drivePath} className="drive-list-item">
|
||||||
@@ -2380,7 +2383,7 @@ export default function DashboardPage({
|
|||||||
size="small"
|
size="small"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
loading={isRescanBusy}
|
loading={isRescanBusy}
|
||||||
disabled={isRescanBusy || isDriveActive}
|
disabled={isRescanBusy || isDriveActive || isCdDriveLocked}
|
||||||
onClick={() => handleRescanDrive(drivePath)}
|
onClick={() => handleRescanDrive(drivePath)}
|
||||||
title="Laufwerk neu lesen"
|
title="Laufwerk neu lesen"
|
||||||
aria-label="Laufwerk neu lesen"
|
aria-label="Laufwerk neu lesen"
|
||||||
@@ -2481,6 +2484,11 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-col dashboard-col-center">
|
<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">
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||||
{jobsLoading ? (
|
{jobsLoading ? (
|
||||||
<p>Jobs werden geladen ...</p>
|
<p>Jobs werden geladen ...</p>
|
||||||
@@ -2530,6 +2538,7 @@ export default function DashboardPage({
|
|||||||
? pipelineForJob.context.selectedMetadata
|
? pipelineForJob.context.selectedMetadata
|
||||||
: {};
|
: {};
|
||||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||||
|
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context);
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
@@ -2552,6 +2561,7 @@ export default function DashboardPage({
|
|||||||
</strong>
|
</strong>
|
||||||
<div className="dashboard-job-badges">
|
<div className="dashboard-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
@@ -2681,6 +2691,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="dashboard-job-badges">
|
<div className="dashboard-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{pluginExecution ? <Tag value={pluginExecution.label} severity="warning" title={pluginExecution.title} /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
@@ -2700,6 +2711,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-col dashboard-col-right">
|
<div className="dashboard-col dashboard-col-right">
|
||||||
@@ -3051,7 +3063,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{monitoringState.enabled ? (
|
{!isSubpageRoute && monitoringState.enabled ? (
|
||||||
<Card title="Hardware Monitoring">
|
<Card title="Hardware Monitoring">
|
||||||
<div className="hardware-monitor-head">
|
<div className="hardware-monitor-head">
|
||||||
{!monitoringState.enabled ? (
|
{!monitoringState.enabled ? (
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
const handleImportOrphanRaw = async (row) => {
|
const handleImportOrphanRaw = async (row) => {
|
||||||
const target = row?.rawPath || row?.folderName || '-';
|
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) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,31 +57,12 @@ export default function DatabasePage() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.importOrphanRawFolder(row.rawPath);
|
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||||
const newJobId = response?.job?.id;
|
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({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Job angelegt',
|
summary: 'Job angelegt',
|
||||||
detail: `Historieneintrag wurde erstellt.`,
|
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
|
||||||
life: 3500
|
life: 3500
|
||||||
});
|
});
|
||||||
}
|
|
||||||
await loadOrphans();
|
await loadOrphans();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Toast } from 'primereact/toast';
|
|||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
@@ -371,6 +373,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||||
const [downloadBusyTarget, setDownloadBusyTarget] = 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 [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const toastRef = useRef(null);
|
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 = () => {
|
const closeDeleteEntryDialog = () => {
|
||||||
if (deleteEntryTargetBusy) {
|
if (deleteEntryTargetBusy) {
|
||||||
return;
|
return;
|
||||||
@@ -1177,12 +1283,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onRestartReview={handleRestartReview}
|
onRestartReview={handleRestartReview}
|
||||||
onReencode={handleReencode}
|
onReencode={handleReencode}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
|
onAssignOmdb={handleAssignOmdb}
|
||||||
|
onAssignCdMetadata={handleAssignCdMetadata}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
onDownloadArchive={handleDownloadArchive}
|
onDownloadArchive={handleDownloadArchive}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
deleteEntryBusy={deleteEntryBusy}
|
deleteEntryBusy={deleteEntryBusy}
|
||||||
downloadBusyTarget={downloadBusyTarget}
|
downloadBusyTarget={downloadBusyTarget}
|
||||||
@@ -1324,6 +1434,31 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--rip-muted: #6a4d38;
|
--rip-muted: #6a4d38;
|
||||||
--rip-panel: #fffaf1;
|
--rip-panel: #fffaf1;
|
||||||
--rip-panel-soft: #fdf5e7;
|
--rip-panel-soft: #fdf5e7;
|
||||||
|
--dashboard-side-width: 20rem;
|
||||||
|
|
||||||
/* PrimeReact theme tokens */
|
/* PrimeReact theme tokens */
|
||||||
--primary-color: var(--rip-brown-600);
|
--primary-color: var(--rip-brown-600);
|
||||||
@@ -238,13 +239,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
width: min(1280px, 96vw);
|
width: 100%;
|
||||||
margin: 1rem auto 2rem;
|
max-width: none;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-upload-banner {
|
.app-upload-banner {
|
||||||
width: min(1280px, 96vw);
|
width: auto;
|
||||||
margin: 0.9rem auto 0;
|
max-width: none;
|
||||||
|
margin: 0.9rem 1rem 0;
|
||||||
padding: 0.8rem 0.95rem;
|
padding: 0.8rem 0.95rem;
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
@@ -316,7 +320,8 @@ body {
|
|||||||
|
|
||||||
.dashboard-3col-grid {
|
.dashboard-3col-grid {
|
||||||
display: 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;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -328,6 +333,18 @@ body {
|
|||||||
min-width: 0;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.dashboard-3col-grid {
|
.dashboard-3col-grid {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
||||||
@@ -3295,6 +3312,19 @@ body {
|
|||||||
color: var(--rip-muted);
|
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 {
|
.spurauswahl-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user