diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 067d4a6..5355651 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -472,6 +472,23 @@ router.post( }) ); +router.post( + '/multipart-merge/:jobId/settings', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const deleteInputsAfterMerge = Boolean(req.body?.deleteInputsAfterMerge); + logger.info('post:multipart-merge:settings', { + reqId: req.reqId, + jobId, + deleteInputsAfterMerge + }); + const job = await pipelineService.updateMultipartMergeSettings(jobId, { + deleteInputsAfterMerge + }); + res.json({ job }); + }) +); + router.get( '/multipart-merge/:jobId/preview', asyncHandler(async (req, res) => { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 71b1d39..a885eb1 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -563,6 +563,22 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn ) { return 'dvd'; } + // Fallback for jobs without complete metadata/structure hints (e.g. multipart history rows). + // Prefer explicit profile folder segments when present. + const normalizedRawPathForHint = rawPath.replace(/\\/g, '/').toLowerCase(); + const normalizedEncodeInputPathForHint = encodeInputPath.replace(/\\/g, '/').toLowerCase(); + if ( + /(^|\/)raw\/bluray(\/|$)/.test(normalizedRawPathForHint) + || /(^|\/)raw\/bluray(\/|$)/.test(normalizedEncodeInputPathForHint) + ) { + return 'bluray'; + } + if ( + /(^|\/)raw\/dvd(\/|$)/.test(normalizedRawPathForHint) + || /(^|\/)raw\/dvd(\/|$)/.test(normalizedEncodeInputPathForHint) + ) { + return 'dvd'; + } return profileHint || 'other'; } @@ -706,6 +722,40 @@ function getSeriesRawPathCandidates(settings = {}) { return unique; } +function resolveRawRootForPath(settings = {}, currentRawDir = null, rawPath = null) { + const normalizedRawPath = normalizeComparablePath(rawPath); + if (!normalizedRawPath) { + return String(currentRawDir || '').trim() || null; + } + + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const normalized = normalizeComparablePath(candidate); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + pushPath(currentRawDir); + for (const candidate of getConfiguredMediaPathList(settings || {}, 'raw_dir')) { + pushPath(candidate); + } + for (const candidate of getSeriesRawPathCandidates(settings || {})) { + pushPath(candidate); + } + + const matchingRoots = unique + .filter((candidateRoot) => isPathInside(candidateRoot, normalizedRawPath)) + .sort((left, right) => right.length - left.length); + if (matchingRoots.length > 0) { + return matchingRoots[0]; + } + return String(currentRawDir || '').trim() || null; +} + function isLikelySeriesRawPath(rawPath, settings = {}) { const normalizedRawPath = normalizeComparablePath(rawPath); if (!normalizedRawPath) { @@ -862,6 +912,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); + rawDir = resolveRawRootForPath(settings || {}, rawDir, effectiveRawPath); const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) : (job?.output_path || null); @@ -6776,7 +6827,9 @@ class HistoryService { error.statusCode = 400; throw error; } else if (!isPathInside(effectiveRawDir, effectiveRawPath)) { - const error = new Error(`RAW-Pfad liegt außerhalb des effektiven RAW-Basispfads: ${effectiveRawPath}`); + const error = new Error( + `RAW-Pfad liegt außerhalb des effektiven RAW-Basispfads. raw_path=${effectiveRawPath} raw_base=${effectiveRawDir}` + ); error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveRawPath)) { @@ -6864,7 +6917,9 @@ class HistoryService { error.statusCode = 400; throw error; } else if (!isPathInside(effectiveMovieDir, effectiveOutputPath)) { - const error = new Error(`Movie-Pfad liegt außerhalb des effektiven Movie-Basispfads: ${effectiveOutputPath}`); + const error = new Error( + `Movie-Pfad liegt außerhalb des effektiven Movie-Basispfads. output_path=${effectiveOutputPath} movie_base=${effectiveMovieDir}` + ); error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveOutputPath)) { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 99bbbce..e485e8e 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -6017,6 +6017,33 @@ function isPathInsideDirectory(parentPath, candidatePath) { return candidate.startsWith(parentWithSep); } +function resolveIncompleteDirectoryFromOutputPath(outputPath, jobId = null) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return null; + } + + const normalizedJobId = normalizePositiveInteger(jobId); + const expectedFolderPattern = normalizedJobId + ? new RegExp(`^incomplete_job-${normalizedJobId}\\s*$`, 'i') + : /^incomplete_job-\d+\s*$/i; + let currentPath = normalizedOutputPath; + let remainingDepth = 32; + while (currentPath && remainingDepth > 0) { + const folderName = String(path.basename(currentPath) || '').trim(); + if (expectedFolderPattern.test(folderName)) { + return currentPath; + } + const parentPath = normalizeComparablePath(path.dirname(currentPath)); + if (!parentPath || parentPath === currentPath) { + break; + } + currentPath = parentPath; + remainingDepth -= 1; + } + return null; +} + function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) { const sourceInputPath = String(inputPath || '').trim(); if (!sourceInputPath) { @@ -11597,6 +11624,7 @@ class PipelineService extends EventEmitter { orderedSourceJobIds, sourceItems, mergeOutputPath, + deleteInputsAfterMerge: Boolean(existingMergePlan?.deleteInputsAfterMerge), mergeStrategy: 'mkvmerge_append', updatedAt: nowIso() }; @@ -11775,6 +11803,131 @@ class PipelineService extends EventEmitter { return historyService.getJobById(normalizedJobId); } + async updateMultipartMergeSettings(jobId, settings = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job || !this.isMultipartMovieMergeHistoryJob(job)) { + const error = new Error('Merge-Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const currentState = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (currentState === 'ENCODING') { + const error = new Error('Merge-Optionen können während des laufenden Merges nicht geändert werden.'); + error.statusCode = 409; + throw error; + } + + const plan = this.safeParseJson(job.encode_plan_json) || {}; + const nextPlan = { ...plan }; + let changed = false; + + if (Object.prototype.hasOwnProperty.call(settings, 'deleteInputsAfterMerge')) { + const nextDeleteInputsAfterMerge = Boolean(settings?.deleteInputsAfterMerge); + if (Boolean(plan?.deleteInputsAfterMerge) !== nextDeleteInputsAfterMerge) { + nextPlan.deleteInputsAfterMerge = nextDeleteInputsAfterMerge; + changed = true; + } + } + + if (!changed) { + return job; + } + + nextPlan.updatedAt = nowIso(); + await historyService.updateJob(normalizedJobId, { + encode_plan_json: JSON.stringify(nextPlan) + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Merge-Option aktualisiert: Input-Dateien nach erfolgreichem Merge ${nextPlan.deleteInputsAfterMerge ? 'löschen' : 'behalten'}.` + ); + return historyService.getJobById(normalizedJobId); + } + + async cleanupMultipartMergeSourceOutputs(sourceItems = [], options = {}) { + const normalizedMergeJobId = this.normalizeQueueJobId(options?.mergeJobId); + const rows = Array.isArray(sourceItems) ? sourceItems : []; + const seenPaths = new Set(); + const deleted = []; + const missing = []; + const failed = []; + + for (const item of rows) { + const sourceJobId = this.normalizeQueueJobId(item?.jobId || item?.sourceJobId); + const outputPath = normalizeComparablePath(item?.outputPath || item?.path || null); + const discNumber = normalizePositiveInteger(item?.discNumber || null); + if (!outputPath || seenPaths.has(outputPath)) { + continue; + } + seenPaths.add(outputPath); + + try { + const existedBefore = fs.existsSync(outputPath); + if (!existedBefore) { + missing.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath + }); + } else { + const stat = fs.lstatSync(outputPath); + if (stat.isDirectory()) { + fs.rmSync(outputPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(outputPath); + } + deleted.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath + }); + } + + if (sourceJobId) { + const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null); + const currentSourceOutputPath = normalizeComparablePath(sourceJob?.output_path || null); + if (currentSourceOutputPath && currentSourceOutputPath === outputPath) { + await historyService.updateJob(sourceJobId, { output_path: null }).catch(() => {}); + } + await historyService.removeJobOutputFolderFromJobs([sourceJobId], outputPath).catch(() => {}); + if (normalizedMergeJobId) { + await historyService.appendLog( + sourceJobId, + 'SYSTEM', + `Merge-Input nach erfolgreichem Merge gelöscht (Merge-Job #${normalizedMergeJobId}): ${outputPath}` + ).catch(() => {}); + } + } + } catch (cleanupError) { + failed.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath, + reason: cleanupError?.message || String(cleanupError) + }); + } + } + + return { + enabled: true, + deletedCount: deleted.length, + missingCount: missing.length, + failedCount: failed.length, + deleted, + missing, + failed + }; + } + async restoreMultipartMergeJobForContainer(containerJobId, options = {}) { const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); if (!normalizedContainerJobId) { @@ -12033,6 +12186,7 @@ class PipelineService extends EventEmitter { const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + const deleteInputsAfterMerge = Boolean(mergePlan?.deleteInputsAfterMerge); if (sourceItems.length < 2) { const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); error.statusCode = 409; @@ -12075,6 +12229,7 @@ class PipelineService extends EventEmitter { jobId: normalizedJobId, mode: 'multipart_merge', mediaProfile, + deleteInputsAfterMerge, outputPath: incompleteOutputPath, sourceItems } @@ -12094,168 +12249,201 @@ class PipelineService extends EventEmitter { const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-')); - let chaptersFilePath = null; - - let runInfo = null; - try { - let chapterPreparationMode = 'source_native'; - let chapterPreparationEntries = 0; - const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); - const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses) - ? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error)) - : []; - if (erroredSources.length > 0) { - await historyService.appendLog( - normalizedJobId, - 'SYSTEM', - `Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).` - ); - } - const mergedChapterContent = String(mergeInputAnalysis?.chapterPlan?.content || '').trim(); - if (mergedChapterContent) { - chaptersFilePath = path.join(mergeTmpDir, `merge-job-${normalizedJobId}-chapters.txt`); - fs.writeFileSync(chaptersFilePath, `${mergedChapterContent}\n`, 'utf8'); - chapterPreparationMode = 'prefixed_from_inputs'; - chapterPreparationEntries = Array.isArray(mergeInputAnalysis?.chapterPlan?.entries) - ? mergeInputAnalysis.chapterPlan.entries.length - : 0; - await historyService.appendLog( - normalizedJobId, - 'SYSTEM', - `Merge-Kapitel vorbereitet (${chapterPreparationEntries} Einträge, Disc-Prefix aktiv).` - ); - } else { - await historyService.appendLog( - normalizedJobId, - 'SYSTEM', - 'Merge-Kapitel konnten nicht normalisiert werden; verwende Kapitel direkt aus den Quellen.' - ); - } - - const mkvmergeArgs = ['-o', incompleteOutputPath]; - if (chaptersFilePath) { - mkvmergeArgs.push('--chapters', chaptersFilePath); - } - sourceItems.forEach((item, sourceIndex) => { - if (sourceIndex > 0) { - mkvmergeArgs.push('+'); + void (async () => { + let chaptersFilePath = null; + let runInfo = null; + try { + let chapterPreparationMode = 'source_native'; + let chapterPreparationEntries = 0; + const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); + const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses) + ? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error)) + : []; + if (erroredSources.length > 0) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).` + ); } + const mergedChapterContent = String(mergeInputAnalysis?.chapterPlan?.content || '').trim(); + if (mergedChapterContent) { + chaptersFilePath = path.join(mergeTmpDir, `merge-job-${normalizedJobId}-chapters.txt`); + fs.writeFileSync(chaptersFilePath, `${mergedChapterContent}\n`, 'utf8'); + chapterPreparationMode = 'prefixed_from_inputs'; + chapterPreparationEntries = Array.isArray(mergeInputAnalysis?.chapterPlan?.entries) + ? mergeInputAnalysis.chapterPlan.entries.length + : 0; + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge-Kapitel vorbereitet (${chapterPreparationEntries} Einträge, Disc-Prefix aktiv).` + ); + } else { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'Merge-Kapitel konnten nicht normalisiert werden; verwende Kapitel direkt aus den Quellen.' + ); + } + + const mkvmergeArgs = ['-o', incompleteOutputPath]; if (chaptersFilePath) { - mkvmergeArgs.push('--no-chapters'); + mkvmergeArgs.push('--chapters', chaptersFilePath); } - mkvmergeArgs.push(item.outputPath); - }); - - runInfo = await this.runCommand({ - jobId: normalizedJobId, - stage: 'ENCODING', - source: 'MKVMERGE', - cmd: mkvmergeCommand, - args: mkvmergeArgs, - parser: parseMkvmergeProgress - }); - const outputFinalization = finalizeOutputPathForCompletedEncode( - incompleteOutputPath, - preferredFinalOutputPath - ); - const finalizedOutputPath = outputFinalization.outputPath; - const outputOwner = String(settings?.movie_dir_owner || '').trim(); - chownRecursive(path.dirname(finalizedOutputPath), outputOwner); - if (outputFinalization.outputPathWithTimestamp) { - await historyService.appendLog( - normalizedJobId, - 'SYSTEM', - `Finaler Merge-Output existierte bereits. Neuer Zielpfad: ${finalizedOutputPath}` - ); - } - await historyService.appendLog(normalizedJobId, 'SYSTEM', `Merge-Output finalisiert: ${finalizedOutputPath}`); - historyService.addJobOutputFolder(normalizedJobId, finalizedOutputPath).catch(() => {}); - - const handbrakeInfo = { - ...runInfo, - mode: 'multipart_merge', - tool: 'mkvmerge', - chapterPreparation: { - mode: chapterPreparationMode, - chapterCount: chapterPreparationEntries - }, - sourceItems, - outputPath: finalizedOutputPath - }; - await historyService.updateJob(normalizedJobId, { - handbrake_info_json: JSON.stringify(handbrakeInfo), - status: 'FINISHED', - last_state: 'FINISHED', - end_time: nowIso(), - rip_successful: 1, - output_path: finalizedOutputPath, - error_message: null - }); - if (mergeJob?.parent_job_id) { - await this.syncSeriesContainerStatusFromChildren(mergeJob.parent_job_id).catch((parentSyncError) => { - logger.warn('multipart-merge:parent-status-sync-failed', { - parentJobId: mergeJob.parent_job_id, - mergeJobId: normalizedJobId, - error: errorToMeta(parentSyncError) - }); - }); - } - if (Number(this.snapshot.activeJobId) === Number(normalizedJobId)) { - await this.setState('FINISHED', { - activeJobId: normalizedJobId, - progress: 100, - eta: null, - statusText: 'Merge abgeschlossen', - context: { - jobId: normalizedJobId, - mode: 'multipart_merge', - outputPath: finalizedOutputPath + sourceItems.forEach((item, sourceIndex) => { + if (sourceIndex > 0) { + mkvmergeArgs.push('+'); } + if (chaptersFilePath) { + mkvmergeArgs.push('--no-chapters'); + } + mkvmergeArgs.push(item.outputPath); }); - setTimeout(async () => { - if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedJobId) { - await this.setState('IDLE', { - finishingJobId: normalizedJobId, - activeJobId: null, - progress: 0, - eta: null, - statusText: 'Bereit', - context: {} + + runInfo = await this.runCommand({ + jobId: normalizedJobId, + stage: 'ENCODING', + source: 'MKVMERGE', + cmd: mkvmergeCommand, + args: mkvmergeArgs, + parser: parseMkvmergeProgress + }); + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + const outputOwner = String(settings?.movie_dir_owner || '').trim(); + chownRecursive(path.dirname(finalizedOutputPath), outputOwner); + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Finaler Merge-Output existierte bereits. Neuer Zielpfad: ${finalizedOutputPath}` + ); + } + await historyService.appendLog(normalizedJobId, 'SYSTEM', `Merge-Output finalisiert: ${finalizedOutputPath}`); + historyService.addJobOutputFolder(normalizedJobId, finalizedOutputPath).catch(() => {}); + + let sourceCleanupSummary = { + enabled: false, + deletedCount: 0, + missingCount: 0, + failedCount: 0, + deleted: [], + missing: [], + failed: [] + }; + if (deleteInputsAfterMerge) { + sourceCleanupSummary = await this.cleanupMultipartMergeSourceOutputs(sourceItems, { + mergeJobId: normalizedJobId + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge-Input-Cleanup: gelöscht ${sourceCleanupSummary.deletedCount}, fehlend ${sourceCleanupSummary.missingCount}, Fehler ${sourceCleanupSummary.failedCount}.` + ); + if (sourceCleanupSummary.failedCount > 0) { + logger.warn('multipart-merge:source-cleanup-partial-failure', { + jobId: normalizedJobId, + failedCount: sourceCleanupSummary.failedCount, + failures: sourceCleanupSummary.failed }); } - }, 3000); - } else { - void this.pumpQueue(); - } - void this.notifyPushover('job_finished', { - title: 'Ripster - Merge abgeschlossen', - message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}` - }); - return { - started: true, - stage: 'ENCODING', - outputPath: finalizedOutputPath - }; - } catch (error) { - if (runInfo) { + } + + const handbrakeInfo = { + ...runInfo, + mode: 'multipart_merge', + tool: 'mkvmerge', + chapterPreparation: { + mode: chapterPreparationMode, + chapterCount: chapterPreparationEntries + }, + sourceItems, + sourceCleanup: sourceCleanupSummary, + outputPath: finalizedOutputPath + }; await historyService.updateJob(normalizedJobId, { - handbrake_info_json: JSON.stringify({ - ...runInfo, - mode: 'multipart_merge', - sourceItems - }) - }).catch(() => {}); + handbrake_info_json: JSON.stringify(handbrakeInfo), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + output_path: finalizedOutputPath, + error_message: null + }); + if (mergeJob?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(mergeJob.parent_job_id).catch((parentSyncError) => { + logger.warn('multipart-merge:parent-status-sync-failed', { + parentJobId: mergeJob.parent_job_id, + mergeJobId: normalizedJobId, + error: errorToMeta(parentSyncError) + }); + }); + } + if (Number(this.snapshot.activeJobId) === Number(normalizedJobId)) { + await this.setState('FINISHED', { + activeJobId: normalizedJobId, + progress: 100, + eta: null, + statusText: 'Merge abgeschlossen', + context: { + jobId: normalizedJobId, + mode: 'multipart_merge', + outputPath: finalizedOutputPath + } + }); + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedJobId) { + await this.setState('IDLE', { + finishingJobId: normalizedJobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } else { + void this.pumpQueue(); + } + void this.notifyPushover('job_finished', { + title: 'Ripster - Merge abgeschlossen', + message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}` + }); + } catch (error) { + if (runInfo) { + await historyService.updateJob(normalizedJobId, { + handbrake_info_json: JSON.stringify({ + ...runInfo, + mode: 'multipart_merge', + sourceItems + }) + }).catch(() => {}); + } + await this.failJob(normalizedJobId, 'ENCODING', error).catch((failError) => { + logger.error('multipart-merge:background-failJob-failed', { + jobId: normalizedJobId, + error: errorToMeta(failError) + }); + }); + } finally { + try { + fs.rmSync(mergeTmpDir, { recursive: true, force: true }); + } catch (_error) { + // ignore tmp cleanup errors + } } - await this.failJob(normalizedJobId, 'ENCODING', error); - error.jobAlreadyFailed = true; - throw error; - } finally { - try { - fs.rmSync(mergeTmpDir, { recursive: true, force: true }); - } catch (_error) { - // ignore tmp cleanup errors - } - } + })(); + + return { + started: true, + stage: 'ENCODING', + outputPath: incompleteOutputPath + }; } isSeriesBatchChildJob(jobLike = null) { @@ -23978,7 +24166,7 @@ class PipelineService extends EventEmitter { async retry(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { - // Retry always starts a rip → bypass the encode queue entirely. + // Retry starts processing immediately (Rip/Merge) and bypasses the encode queue. return this.retry(jobId, { ...options, immediate: true }); } @@ -24001,6 +24189,7 @@ class PipelineService extends EventEmitter { const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const sourceJobKind = this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan }); const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: sourceMakemkvInfo, encodePlan: sourceEncodePlan @@ -24011,6 +24200,39 @@ class PipelineService extends EventEmitter { ); const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; + const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge'; + const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); + let mergeContainerJobId = null; + if (isMultipartMergeRetry) { + mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId); + if (!mergeContainerJobId && sourceParentJobId) { + const directParentJob = await historyService.getJobById(sourceParentJobId).catch(() => null); + if (directParentJob && this.isMultipartMovieContainerHistoryJob(directParentJob)) { + mergeContainerJobId = sourceParentJobId; + } else if (directParentJob && this.isMultipartMovieMergeHistoryJob(directParentJob)) { + const nestedContainerJobId = this.normalizeQueueJobId(directParentJob?.parent_job_id); + if (nestedContainerJobId) { + const nestedParentJob = await historyService.getJobById(nestedContainerJobId).catch(() => null); + if (nestedParentJob && this.isMultipartMovieContainerHistoryJob(nestedParentJob)) { + mergeContainerJobId = nestedContainerJobId; + } + } + } + } + if (mergeContainerJobId) { + const mergeContainerJob = await historyService.getJobById(mergeContainerJobId).catch(() => null); + if (!mergeContainerJob || !this.isMultipartMovieContainerHistoryJob(mergeContainerJob)) { + mergeContainerJobId = null; + } + } + if (!mergeContainerJobId) { + const error = new Error( + 'Retry für Merge-Job nicht möglich: Multipart-Container konnte nicht aufgelöst werden.' + ); + error.statusCode = 409; + throw error; + } + } // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) @@ -24108,7 +24330,7 @@ class PipelineService extends EventEmitter { selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) }; } // end else (sourceTracks.length > 0) - } else if (!isAudiobookRetry) { + } else if (!isAudiobookRetry && !isMultipartMergeRetry) { const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( retrySettings, @@ -24176,9 +24398,31 @@ class PipelineService extends EventEmitter { } } + const retryInitialStatus = isCdRetry + ? 'CD_READY_TO_RIP' + : (isAudiobookRetry + ? 'READY_TO_START' + : (isMultipartMergeRetry ? 'READY_TO_START' : 'RIPPING')); + const retryParentJobId = isMultipartMergeRetry + ? mergeContainerJobId + : this.normalizeQueueJobId(jobId); + + let retryEncodePlanJson = (isCdRetry || isAudiobookRetry || isMultipartMergeRetry) + ? (sourceJob.encode_plan_json || null) + : null; + if (isMultipartMergeRetry && sourceEncodePlan && typeof sourceEncodePlan === 'object' && retryParentJobId) { + retryEncodePlanJson = JSON.stringify({ + ...sourceEncodePlan, + mode: 'multipart_merge', + jobKind: 'multipart_movie_merge', + containerJobId: Number(retryParentJobId), + updatedAt: nowIso() + }); + } + const retryJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, - status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), + status: retryInitialStatus, detectedTitle: sourceJob.detected_title || sourceJob.title || null, jobKind: this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan @@ -24215,7 +24459,7 @@ class PipelineService extends EventEmitter { } const retryUpdatePayload = { - parent_job_id: Number(jobId), + parent_job_id: retryParentJobId ? Number(retryParentJobId) : null, media_type: preservedRetryMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, @@ -24229,15 +24473,13 @@ class PipelineService extends EventEmitter { end_time: null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: (isCdRetry || isAudiobookRetry) - ? (sourceJob.encode_plan_json || null) - : null, + encode_plan_json: retryEncodePlanJson, encode_input_path: retryEncodeInputPath, encode_review_confirmed: isAudiobookRetry ? 1 : 0, output_path: null, raw_path: retryRawPath, - status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), - last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING') + status: retryInitialStatus, + last_state: retryInitialStatus }; await historyService.updateJob(retryJobId, retryUpdatePayload); @@ -24252,14 +24494,16 @@ class PipelineService extends EventEmitter { await historyService.appendLog( retryJobId, 'USER_ACTION', - `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).` + `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : (isMultipartMergeRetry ? 'Merge' : 'Disc'))}).` ); if (preservedRetryMediaType && !explicitSourceMediaType) { await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); } this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); await historyService.retireJobInFavorOf(jobId, retryJobId, { - reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry') + reason: isCdRetry + ? 'cd_retry' + : (isAudiobookRetry ? 'audiobook_retry' : (isMultipartMergeRetry ? 'merge_retry' : 'retry')) }); this.cancelRequestedByJob.delete(retryJobId); @@ -24280,6 +24524,14 @@ class PipelineService extends EventEmitter { queued: false, stage: 'READY_TO_START' }; + } else if (isMultipartMergeRetry) { + this.startMultipartMergeJob(retryJobId).catch((error) => { + logger.error('retry:multipart-merge:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); + }); } else { this.startRipEncode(retryJobId).catch((error) => { logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); @@ -25969,6 +26221,29 @@ class PipelineService extends EventEmitter { 'SYSTEM', `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}` ); + if (finalState === 'CANCELLED') { + const incompleteOutputDir = resolveIncompleteDirectoryFromOutputPath(job?.output_path, jobId); + if (incompleteOutputDir) { + try { + const existedBeforeCleanup = fs.existsSync(incompleteOutputDir); + fs.rmSync(incompleteOutputDir, { recursive: true, force: true }); + if (existedBeforeCleanup) { + logger.info('job:cancelled:incomplete-output-cleanup', { + jobId, + stage: normalizedStage || null, + incompleteOutputDir + }); + } + } catch (cleanupError) { + logger.warn('job:cancelled:incomplete-output-cleanup-failed', { + jobId, + stage: normalizedStage || null, + incompleteOutputDir, + error: errorToMeta(cleanupError) + }); + } + } + } const jobProgressContext = this.jobProgress.get(Number(jobId))?.context; const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 751c2c4..f79c53c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -650,6 +650,16 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, + async updateMultipartMergeSettings(jobId, settings = {}) { + const result = await request(`/pipeline/multipart-merge/${jobId}/settings`, { + method: 'POST', + body: JSON.stringify({ + deleteInputsAfterMerge: Boolean(settings?.deleteInputsAfterMerge) + }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, getMultipartMergePreview(jobId) { return request(`/pipeline/multipart-merge/${jobId}/preview`); }, diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 77560cc..6af4faa 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -5,6 +5,7 @@ import { Tag } from 'primereact/tag'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; +import mergeIndicatorIcon from '../assets/media-merge.svg'; import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation'; import { isSeriesVideoJob } from '../utils/jobTaxonomy'; @@ -1233,6 +1234,14 @@ export default function JobDetailDialog({ const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container'; const isMultipartContainer = jobKindRaw === 'multipart_movie_container'; + const isMultipartChild = jobKindRaw === 'multipart_movie_child' + || ( + Number(job?.is_multipart_movie || 0) === 1 + && Boolean(normalizePositiveInteger(job?.parent_job_id)) + && !isMultipartMergeJob + && !isMultipartContainer + ); + const isMultipartJob = Boolean(isMultipartContainer || isMultipartMergeJob || isMultipartChild); const isDiskContainer = isSeriesContainer || isMultipartContainer; const allChildJobs = Array.isArray(job?.children) ? [...job.children].sort(compareSeriesChildJobsByDisc) @@ -1446,10 +1455,18 @@ export default function JobDetailDialog({ }; }; const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio'); + const dialogHeader = ( + + {`Job #${job?.id || ''}`} + {isMultipartJob ? ( + Multipart + ) : null} + + ); return ( - Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs). + {childRawDeleteDescription}
) : null} diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index 0b942ee..3d714b6 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -16,6 +16,7 @@ import ReencodeConflictModal from '../components/ReencodeConflictModal'; import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; +import mergeIndicatorIcon from '../assets/media-merge.svg'; import { getStatusLabel, getStatusSeverity, @@ -213,6 +214,23 @@ function isMultipartMergeHistoryRow(row) { return kind === 'multipart_movie_merge'; } +function isMultipartChildHistoryRow(row) { + const kind = String( + row?.job_kind + || row?.jobKind + || row?.encodePlan?.jobKind + || '' + ).trim().toLowerCase(); + return kind === 'multipart_movie_child'; +} + +function isMultipartHistoryRow(row) { + if (isMultipartContainerHistoryRow(row) || isMultipartMergeHistoryRow(row) || isMultipartChildHistoryRow(row)) { + return true; + } + return Number(row?.is_multipart_movie || 0) === 1; +} + function isSeriesChildHistoryRow(row) { const kind = String( row?.job_kind @@ -361,6 +379,9 @@ function getOutputLabelForRow(row) { if (isMultipartMergeHistoryRow(row)) { return 'Merge-Datei(en)'; } + if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) { + return 'Movie-Datei(en)'; + } if (isSeriesVideoJob(row)) { return 'Folgen-Datei(en)'; } @@ -377,6 +398,9 @@ function getOutputShortLabelForRow(row) { if (isMultipartMergeHistoryRow(row)) { return 'Merge'; } + if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) { + return 'Movie'; + } if (isSeriesVideoJob(row)) { return 'Folgen'; } @@ -958,6 +982,7 @@ export default function HistoryPage({ refreshToken = 0 }) { const handleDeleteFiles = async (row, target) => { const isContainerRow = isContainerHistoryRow(row); const isMultipartContainerRow = isMultipartContainerHistoryRow(row); + const isMultipartChildRow = isMultipartChildHistoryRow(row); const isSeriesChildRow = isSeriesChildHistoryRow(row); const includeRelated = isContainerRow || (isSeriesChildRow && (target === 'movie' || target === 'both')); @@ -981,7 +1006,9 @@ export default function HistoryPage({ refreshToken = 0 }) { : isSeriesChildRow && target === 'raw' ? '\nScope: nur RAW dieses Disk-Jobs.' : isSeriesChildRow && (target === 'movie' || target === 'both') - ? '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Folgen).' + ? (isMultipartChildRow + ? '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Movie).' + : '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Folgen).') : includeRelated ? '\nScope: verknüpfte Jobs werden einbezogen.' : ''; @@ -1509,6 +1536,10 @@ export default function HistoryPage({ refreshToken = 0 }) { if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) { return; } + const isMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow); + if (isMergeJob && (normalizedTarget === 'raw' || normalizedTarget === 'both')) { + return; + } const jobId = Number(deleteEntryDialogRow?.id || 0); if (!jobId) { return; @@ -1647,6 +1678,70 @@ export default function HistoryPage({ refreshToken = 0 }) { } }; + const handleCancelJob = async (row) => { + const jobId = normalizeJobId(row?.id || row); + if (!jobId) { + return; + } + + const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + const initialStatus = String(row?.status || row?.last_state || '').trim().toUpperCase(); + const fetchLatestJob = async () => { + try { + const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true }); + return response?.job && typeof response.job === 'object' ? response.job : null; + } catch (_error) { + return null; + } + }; + + setActionBusy(true); + try { + const response = await api.cancelPipeline(jobId); + const result = response?.result && typeof response.result === 'object' ? response.result : {}; + + // Persisted job status can lag behind the cancel request for a short moment. + // Poll briefly so the history/detail view reflects cancellation without full page reload. + let latestJob = await fetchLatestJob(); + if (runningStates.has(initialStatus)) { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + for (let attempt = 0; attempt < 8; attempt += 1) { + const latestStatus = String(latestJob?.status || latestJob?.last_state || '').trim().toUpperCase(); + if (latestStatus && !runningStates.has(latestStatus)) { + break; + } + await wait(250); + latestJob = await fetchLatestJob(); + } + } + + await load(); + if (detailVisible && Number(selectedJob?.id || 0) === Number(jobId) && latestJob) { + setSelectedJob(latestJob); + } else { + await refreshDetailIfOpen(jobId); + } + + toastRef.current?.show({ + severity: 'success', + summary: result?.queuedOnly ? 'Aus Queue entfernt' : 'Job abgebrochen', + detail: result?.queuedOnly + ? `Job #${jobId} wurde aus der Warteschlange entfernt.` + : `Job #${jobId} wurde abgebrochen.`, + life: 3500 + }); + } catch (error) { + toastRef.current?.show({ + severity: 'error', + summary: 'Abbruch fehlgeschlagen', + detail: error.message, + life: 4500 + }); + } finally { + setActionBusy(false); + } + }; + const handleRestoreMultipartMerge = async (row) => { const containerJobId = normalizeJobId(row?.id || row); if (!containerJobId) { @@ -1685,10 +1780,25 @@ export default function HistoryPage({ refreshToken = 0 }) { : null; if (mergeSummary) { const mergeState = String(mergeSummary?.state || '').trim().toLowerCase(); + const mergeActive = Boolean(mergeSummary?.active) || mergeState === 'active'; const hasMergeJob = Boolean(mergeSummary?.hasJob); const isCompleted = Boolean(mergeSummary?.completed); const filesReady = Boolean(mergeSummary?.ready); const mergeVisibleInRipper = hasMergeJob && !isCompleted; + if (mergeActive) { + return ( +
+ +
+ ); + } + if (mergeState === 'done' || isCompleted) { + return ( +
+ +
+ ); + } if (mergeVisibleInRipper) { return (
@@ -1711,13 +1821,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
); } - if (mergeState === 'done') { - return ( -
- -
- ); - } } } if (isMultipartMergeHistoryRow(row) && normalizeStatus(row?.status) === 'ENCODING') { @@ -1763,6 +1866,13 @@ export default function HistoryPage({ refreshToken = 0 }) { ); }; + const renderStateChip = (label, valueLabel, tone = 'tone-warn', iconClass = 'pi-spinner pi-spin') => ( + +