0.15.0-1 ---

This commit is contained in:
2026-04-23 19:10:33 +00:00
parent 120a9e6ad1
commit 3e5840f329
7 changed files with 886 additions and 255 deletions
+17
View File
@@ -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) => {
+57 -2
View File
@@ -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)) {
+439 -164
View File
@@ -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