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 ? (
+
+ ) : null}
+
+ );
return (
- Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).
+ {childRawDeleteDescription}