diff --git a/backend/package-lock.json b/backend/package-lock.json index 0c5a147..b20ee52 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-4", + "version": "0.12.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-4", + "version": "0.12.0-5", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index cb22e63..259b479 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-4", + "version": "0.12.0-5", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 79939a8..ab2a052 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -1048,20 +1048,20 @@ async function migrateSettingsSchemaMetadata(db) { { key: 'use_plugin_architecture_bluray_review', label: 'Review / Mediainfo-Prüfung (Blu-ray)', - description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', - defaultValue: 'false', + description: 'HandBrake-Scan läuft über das Blu-ray-Plugin.', + defaultValue: 'true', orderIndex: 10003, dependsOn: 'use_plugin_architecture_bluray', - validationJson: '{"readonly":true}' + validationJson: '{}' }, { key: 'use_plugin_architecture_bluray_encode', label: 'Encoding (Blu-ray)', - description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', - defaultValue: 'false', + description: 'Encoding läuft über das Blu-ray-Plugin.', + defaultValue: 'true', orderIndex: 10004, dependsOn: 'use_plugin_architecture_bluray', - validationJson: '{"readonly":true}' + validationJson: '{}' }, // ── DVD ────────────────────────────────────────────────────────────────── { @@ -1159,11 +1159,11 @@ async function migrateSettingsSchemaMetadata(db) { { key: 'use_plugin_architecture_audiobook_encode', label: 'Encoding (Audiobook)', - description: 'Noch nicht implementiert — Encoding läuft noch über Legacy.', - defaultValue: 'false', + description: 'Encoding läuft über das Audiobook-Plugin.', + defaultValue: 'true', orderIndex: 10032, dependsOn: 'use_plugin_architecture_audiobook', - validationJson: '{"readonly":true}' + validationJson: '{}' } ]; for (const setting of pluginArchitectureSettings) { diff --git a/backend/src/plugins/AudiobookPlugin.js b/backend/src/plugins/AudiobookPlugin.js index efad71b..25a7b56 100644 --- a/backend/src/plugins/AudiobookPlugin.js +++ b/backend/src/plugins/AudiobookPlugin.js @@ -17,14 +17,14 @@ const { ensureDir } = require('../utils/files'); * * Deshalb: * rip() → No-Op (Datei liegt bereits im rawDir) - * encode() → ffmpeg-Encoding (Single-File oder per Chapter) * review() → null (kein HandBrake-Preview) + * encode() → ffmpeg Single-File oder kapitelweise * * 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 + * filePath - Absoluter Pfad zur .aax-Datei * * Pflicht-Felder in ctx.extra für encode(): * inputPath - Absoluter Pfad zur .aax-Input-Datei @@ -132,8 +132,7 @@ class AudiobookPlugin extends SourcePlugin { } /** - * Encodiert die .aax-Datei mit ffmpeg. - * Unterstützt Single-File (M4B) und kapitelweise Ausgabe (MP3, FLAC). + * Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise. * * @param {object} job * @param {PluginContext} ctx @@ -146,6 +145,12 @@ class AudiobookPlugin extends SourcePlugin { 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 { inputPath, outputPath, @@ -171,7 +176,6 @@ class AudiobookPlugin extends SourcePlugin { 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); @@ -203,7 +207,6 @@ class AudiobookPlugin extends SourcePlugin { outputPath }); - // Ergebnis für den Orchestrator ablegen ctx.extra.encodeResult = { outputPath, format: normalizedFormat, @@ -233,6 +236,18 @@ class AudiobookPlugin extends SourcePlugin { */ getSettingsSchema() { 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', category: 'Tools', @@ -283,10 +298,7 @@ class AudiobookPlugin extends SourcePlugin { outputPath, normalizedFormat, normalizedOptions, - { - activationBytes, - metadata - } + { activationBytes, metadata } ); ctx.logger.info('audiobook:encode:single-file', { diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js index cb594bf..e1e7d7d 100644 --- a/backend/src/plugins/VideoDiscPlugin.js +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -153,16 +153,27 @@ class VideoDiscPlugin extends SourcePlugin { const reviewSilent = Boolean(ctx.extra?.reviewSilent); let runInfo; if (runCommandFn) { - runInfo = await runCommandFn({ - jobId: job?.id, - stage: reviewStage, - source: reviewSource, - cmd: scanConfig.cmd, - args: scanConfig.args, - collectLines: scanLines, - collectStderrLines: false, - silent: reviewSilent - }); + try { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: reviewStage, + source: reviewSource, + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: scanLines, + collectStderrLines: false, + 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 { runInfo = await _runCommandCaptured({ cmd: scanConfig.cmd, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 2ea6250..96ad58d 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -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) { await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', @@ -9258,6 +9267,17 @@ class PipelineService extends EventEmitter { } 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 reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, @@ -11824,7 +11844,8 @@ class PipelineService extends EventEmitter { await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...error.runInfo, - analyzeContext: mkInfoBeforeRip?.analyzeContext || null + analyzeContext: mkInfoBeforeRip?.analyzeContext || null, + pluginExecution: mkInfoBeforeRip?.pluginExecution || null }, mediaProfile)) }); } @@ -11887,10 +11908,17 @@ class PipelineService extends EventEmitter { const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; - // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs) + // 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) || ['ERROR', 'CANCELLED'].includes(sourceLastState) - || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); + || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)) + || isImportedAudiobookWithoutEncode; if (!retryable) { const error = new Error( `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; } + 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); if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { 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}` }); - let temporaryChapterMetadataPath = null; - - // Activation Bytes für AAX-Dateien aus Cache lesen + // Activation Bytes für AAX-Dateien aus Cache lesen (vor dem Background-Start, + // damit ein Fehler hier den HTTP-Request direkt abbricht). let encodeActivationBytes = null; if (path.extname(inputPath).toLowerCase() === '.aax') { try { @@ -14063,7 +14099,11 @@ class PipelineService extends EventEmitter { } } - try { + // 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 { let ffmpegRunInfo = null; if (isSplitOutput) { const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles) @@ -14346,8 +14386,6 @@ class PipelineService extends EventEmitter { } logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) }); await this.failJob(jobId, 'ENCODING', error); - error.jobAlreadyFailed = true; - throw error; } finally { if (temporaryChapterMetadataPath) { try { @@ -14357,6 +14395,9 @@ class PipelineService extends EventEmitter { } } } + })(); + + return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath }; } // ── CD Pipeline ───────────────────────────────────────────────────────────── diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e10bda1..1fb6686 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-4", + "version": "0.12.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-4", + "version": "0.12.0-5", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 5773588..4a68bce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-4", + "version": "0.12.0-5", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 2205bfb..97fcbdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.12.0-4", + "version": "0.12.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.12.0-4", + "version": "0.12.0-5", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index c69b544..c9d5c72 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.12.0-4", + "version": "0.12.0-5", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend", @@ -10,6 +10,7 @@ "build:frontend": "npm run build --prefix frontend", "qa:cd:analysis": "node ./scripts/smoketest/qa-cd-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:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh", "qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh",