0.12.0-5 Pre-Plugin

This commit is contained in:
2026-03-22 10:11:46 +00:00
parent c0350644b9
commit adbc5e51b5
10 changed files with 112 additions and 47 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-4", "version": "0.12.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-4", "version": "0.12.0-5",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-4", "version": "0.12.0-5",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+9 -9
View File
@@ -1048,20 +1048,20 @@ async function migrateSettingsSchemaMetadata(db) {
{ {
key: 'use_plugin_architecture_bluray_review', key: 'use_plugin_architecture_bluray_review',
label: 'Review / Mediainfo-Prüfung (Blu-ray)', label: 'Review / Mediainfo-Prüfung (Blu-ray)',
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', description: 'HandBrake-Scan läuft über das Blu-ray-Plugin.',
defaultValue: 'false', defaultValue: 'true',
orderIndex: 10003, orderIndex: 10003,
dependsOn: 'use_plugin_architecture_bluray', dependsOn: 'use_plugin_architecture_bluray',
validationJson: '{"readonly":true}' validationJson: '{}'
}, },
{ {
key: 'use_plugin_architecture_bluray_encode', key: 'use_plugin_architecture_bluray_encode',
label: 'Encoding (Blu-ray)', label: 'Encoding (Blu-ray)',
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', description: 'Encoding läuft über das Blu-ray-Plugin.',
defaultValue: 'false', defaultValue: 'true',
orderIndex: 10004, orderIndex: 10004,
dependsOn: 'use_plugin_architecture_bluray', dependsOn: 'use_plugin_architecture_bluray',
validationJson: '{"readonly":true}' validationJson: '{}'
}, },
// ── DVD ────────────────────────────────────────────────────────────────── // ── DVD ──────────────────────────────────────────────────────────────────
{ {
@@ -1159,11 +1159,11 @@ async function migrateSettingsSchemaMetadata(db) {
{ {
key: 'use_plugin_architecture_audiobook_encode', key: 'use_plugin_architecture_audiobook_encode',
label: 'Encoding (Audiobook)', label: 'Encoding (Audiobook)',
description: 'Noch nicht implementiert — Encoding läuft noch über Legacy.', description: 'Encoding läuft über das Audiobook-Plugin.',
defaultValue: 'false', defaultValue: 'true',
orderIndex: 10032, orderIndex: 10032,
dependsOn: 'use_plugin_architecture_audiobook', dependsOn: 'use_plugin_architecture_audiobook',
validationJson: '{"readonly":true}' validationJson: '{}'
} }
]; ];
for (const setting of pluginArchitectureSettings) { for (const setting of pluginArchitectureSettings) {
+21 -9
View File
@@ -17,8 +17,8 @@ const { ensureDir } = require('../utils/files');
* *
* Deshalb: * Deshalb:
* rip() → No-Op (Datei liegt bereits im rawDir) * rip() → No-Op (Datei liegt bereits im rawDir)
* encode() → ffmpeg-Encoding (Single-File oder per Chapter)
* review() → null (kein HandBrake-Preview) * review() → null (kein HandBrake-Preview)
* encode() → ffmpeg Single-File oder kapitelweise
* *
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für * detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
* Datei-Uploads) oder eine Dateiendung im discInfo. * Datei-Uploads) oder eine Dateiendung im discInfo.
@@ -132,8 +132,7 @@ class AudiobookPlugin extends SourcePlugin {
} }
/** /**
* Encodiert die .aax-Datei mit ffmpeg. * Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise.
* Unterstützt Single-File (M4B) und kapitelweise Ausgabe (MP3, FLAC).
* *
* @param {object} job * @param {object} job
* @param {PluginContext} ctx * @param {PluginContext} ctx
@@ -146,6 +145,12 @@ class AudiobookPlugin extends SourcePlugin {
pluginFile: 'AudiobookPlugin.js' pluginFile: 'AudiobookPlugin.js'
}); });
const settings = await ctx.settings.getSettingsMap();
if (!settings.audiobook_encoding_enabled) {
ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id });
return;
}
const { const {
inputPath, inputPath,
outputPath, outputPath,
@@ -171,7 +176,6 @@ class AudiobookPlugin extends SourcePlugin {
throw error; throw error;
} }
const settings = await ctx.settings.getSettingsMap();
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat); const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions); const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
@@ -203,7 +207,6 @@ class AudiobookPlugin extends SourcePlugin {
outputPath outputPath
}); });
// Ergebnis für den Orchestrator ablegen
ctx.extra.encodeResult = { ctx.extra.encodeResult = {
outputPath, outputPath,
format: normalizedFormat, format: normalizedFormat,
@@ -233,6 +236,18 @@ class AudiobookPlugin extends SourcePlugin {
*/ */
getSettingsSchema() { getSettingsSchema() {
return [ return [
{
key: 'audiobook_encoding_enabled',
category: 'Features',
label: 'Audiobook Encoding',
type: 'boolean',
required: 1,
description: 'Audiobook-Encoding über das Plugin aktivieren.',
default_value: 'true',
options_json: '[]',
validation_json: '{}',
order_index: 100
},
{ {
key: 'ffmpeg_command', key: 'ffmpeg_command',
category: 'Tools', category: 'Tools',
@@ -283,10 +298,7 @@ class AudiobookPlugin extends SourcePlugin {
outputPath, outputPath,
normalizedFormat, normalizedFormat,
normalizedOptions, normalizedOptions,
{ { activationBytes, metadata }
activationBytes,
metadata
}
); );
ctx.logger.info('audiobook:encode:single-file', { ctx.logger.info('audiobook:encode:single-file', {
+11
View File
@@ -153,6 +153,7 @@ class VideoDiscPlugin extends SourcePlugin {
const reviewSilent = Boolean(ctx.extra?.reviewSilent); const reviewSilent = Boolean(ctx.extra?.reviewSilent);
let runInfo; let runInfo;
if (runCommandFn) { if (runCommandFn) {
try {
runInfo = await runCommandFn({ runInfo = await runCommandFn({
jobId: job?.id, jobId: job?.id,
stage: reviewStage, stage: reviewStage,
@@ -163,6 +164,16 @@ class VideoDiscPlugin extends SourcePlugin {
collectStderrLines: false, collectStderrLines: false,
silent: reviewSilent silent: reviewSilent
}); });
} catch (cmdError) {
// runCommand collected stdout lines via callbacks before throwing.
// Recover the runInfo from the error so the caller can still parse
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
runInfo = cmdError?.runInfo || {
status: 'ERROR',
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
errorMessage: cmdError?.message || String(cmdError)
};
}
} else { } else {
runInfo = await _runCommandCaptured({ runInfo = await _runCommandCaptured({
cmd: scanConfig.cmd, cmd: scanConfig.cmd,
+49 -8
View File
@@ -8933,6 +8933,15 @@ class PipelineService extends EventEmitter {
} }
} }
// An Incomplete_ raw folder means the rip never finished — skip it and re-rip.
if (existingRawInput && resolveRawFolderStateFromPath(job.raw_path) === RAW_FOLDER_STATES.INCOMPLETE) {
logger.info('startPreparedJob:raw-incomplete-skip', {
jobId,
rawPath: job.raw_path
});
existingRawInput = null;
}
if (existingRawInput) { if (existingRawInput) {
await historyService.updateJob(jobId, { await historyService.updateJob(jobId, {
status: 'MEDIAINFO_CHECK', status: 'MEDIAINFO_CHECK',
@@ -9258,6 +9267,17 @@ class PipelineService extends EventEmitter {
} }
const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
// Importierte Jobs (RAW-Ordner-Import) müssen zuerst vollständig analysiert und
// encodiert werden, bevor ein Re-Encode möglich ist. Nur "Neu einlesen" ist erlaubt.
if (mkInfo?.source === 'orphan_raw_import' && !sourceJob.handbrake_info_json) {
const error = new Error(
'Re-Encode nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.'
);
error.statusCode = 409;
throw error;
}
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
makemkvInfo: mkInfo, makemkvInfo: mkInfo,
@@ -11824,7 +11844,8 @@ class PipelineService extends EventEmitter {
await historyService.updateJob(jobId, { await historyService.updateJob(jobId, {
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
...error.runInfo, ...error.runInfo,
analyzeContext: mkInfoBeforeRip?.analyzeContext || null analyzeContext: mkInfoBeforeRip?.analyzeContext || null,
pluginExecution: mkInfoBeforeRip?.pluginExecution || null
}, mediaProfile)) }, mediaProfile))
}); });
} }
@@ -11887,10 +11908,17 @@ 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) // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs).
// Importierte Audiobooks (orphan_raw_import ohne bisherigen Encode) dürfen ebenfalls
// neu gestartet werden, da erst der vollständige Flow durchlaufen werden muss.
const isImportedAudiobookWithoutEncode = isAudiobookRetry
&& sourceStatus === 'FINISHED'
&& sourceMakemkvInfo?.source === 'orphan_raw_import'
&& !sourceJob.handbrake_info_json;
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|| ['ERROR', 'CANCELLED'].includes(sourceLastState) || ['ERROR', 'CANCELLED'].includes(sourceLastState)
|| (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus))
|| isImportedAudiobookWithoutEncode;
if (!retryable) { if (!retryable) {
const error = new Error( const error = new Error(
`Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
@@ -12293,6 +12321,15 @@ class PipelineService extends EventEmitter {
throw error; throw error;
} }
const mkInfoForRestart = this.safeParseJson(job.makemkv_info_json);
if (mkInfoForRestart?.source === 'orphan_raw_import' && !job.handbrake_info_json) {
const error = new Error(
'Encode-Neustart nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.'
);
error.statusCode = 409;
throw error;
}
const encodePlan = this.safeParseJson(job.encode_plan_json); const encodePlan = this.safeParseJson(job.encode_plan_json);
if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) {
const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.'); const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.');
@@ -14045,9 +14082,8 @@ class PipelineService extends EventEmitter {
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
}); });
let temporaryChapterMetadataPath = null; // Activation Bytes für AAX-Dateien aus Cache lesen (vor dem Background-Start,
// damit ein Fehler hier den HTTP-Request direkt abbricht).
// Activation Bytes für AAX-Dateien aus Cache lesen
let encodeActivationBytes = null; let encodeActivationBytes = null;
if (path.extname(inputPath).toLowerCase() === '.aax') { if (path.extname(inputPath).toLowerCase() === '.aax') {
try { try {
@@ -14063,6 +14099,10 @@ class PipelineService extends EventEmitter {
} }
} }
// Den eigentlichen ffmpeg-Encode im Hintergrund ausführen, damit der HTTP-Request
// sofort nach dem Setzen des ENCODING-Status zurückkehrt (kein Blockieren bis FINISHED).
void (async () => {
let temporaryChapterMetadataPath = null;
try { try {
let ffmpegRunInfo = null; let ffmpegRunInfo = null;
if (isSplitOutput) { if (isSplitOutput) {
@@ -14346,8 +14386,6 @@ class PipelineService extends EventEmitter {
} }
logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) }); logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) });
await this.failJob(jobId, 'ENCODING', error); await this.failJob(jobId, 'ENCODING', error);
error.jobAlreadyFailed = true;
throw error;
} finally { } finally {
if (temporaryChapterMetadataPath) { if (temporaryChapterMetadataPath) {
try { try {
@@ -14357,6 +14395,9 @@ class PipelineService extends EventEmitter {
} }
} }
} }
})();
return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath };
} }
// ── CD Pipeline ───────────────────────────────────────────────────────────── // ── CD Pipeline ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-4", "version": "0.12.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-4", "version": "0.12.0-5",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-4", "version": "0.12.0-5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.12.0-4", "version": "0.12.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.12.0-4", "version": "0.12.0-5",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.12.0-4", "version": "0.12.0-5",
"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",
@@ -10,6 +10,7 @@
"build:frontend": "npm run build --prefix frontend", "build:frontend": "npm run build --prefix frontend",
"qa:cd:analysis": "node ./scripts/smoketest/qa-cd-analysis.js", "qa:cd:analysis": "node ./scripts/smoketest/qa-cd-analysis.js",
"qa:dvd:analysis": "node ./scripts/smoketest/qa-dvd-analysis.js", "qa:dvd:analysis": "node ./scripts/smoketest/qa-dvd-analysis.js",
"qa:bluray:analysis": "node ./scripts/smoketest/qa-bluray-analysis.js",
"qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js", "qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js",
"qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh", "qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh",
"qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh", "qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh",