0.15.0-1 ---
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,8 +12249,8 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
|
||||
void (async () => {
|
||||
let chaptersFilePath = null;
|
||||
|
||||
let runInfo = null;
|
||||
try {
|
||||
let chapterPreparationMode = 'source_native';
|
||||
@@ -12171,6 +12326,33 @@ class PipelineService extends EventEmitter {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handbrakeInfo = {
|
||||
...runInfo,
|
||||
mode: 'multipart_merge',
|
||||
@@ -12180,6 +12362,7 @@ class PipelineService extends EventEmitter {
|
||||
chapterCount: chapterPreparationEntries
|
||||
},
|
||||
sourceItems,
|
||||
sourceCleanup: sourceCleanupSummary,
|
||||
outputPath: finalizedOutputPath
|
||||
};
|
||||
await historyService.updateJob(normalizedJobId, {
|
||||
@@ -12231,11 +12414,6 @@ class PipelineService extends EventEmitter {
|
||||
title: 'Ripster - Merge abgeschlossen',
|
||||
message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}`
|
||||
});
|
||||
return {
|
||||
started: true,
|
||||
stage: 'ENCODING',
|
||||
outputPath: finalizedOutputPath
|
||||
};
|
||||
} catch (error) {
|
||||
if (runInfo) {
|
||||
await historyService.updateJob(normalizedJobId, {
|
||||
@@ -12246,9 +12424,12 @@ class PipelineService extends EventEmitter {
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.failJob(normalizedJobId, 'ENCODING', error);
|
||||
error.jobAlreadyFailed = true;
|
||||
throw error;
|
||||
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 });
|
||||
@@ -12256,6 +12437,13 @@ class PipelineService extends EventEmitter {
|
||||
// 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
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
<span className="job-step-cell">
|
||||
<span>{`Job #${job?.id || ''}`}</span>
|
||||
{isMultipartJob ? (
|
||||
<img src={mergeIndicatorIcon} alt="Multipart" title="Multipart" className="media-indicator-icon" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={`Job #${job?.id || ''}`}
|
||||
header={dialogHeader}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '70rem', maxWidth: '96vw' }}
|
||||
@@ -2301,6 +2318,18 @@ export default function JobDetailDialog({
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
|
||||
const childActionState = resolveChildActionState(child);
|
||||
const childOutputDeleteLabel = isMultipartContainer
|
||||
? 'Movie löschen (Eintrag bleibt)'
|
||||
: 'Folgen löschen (Eintrag bleibt)';
|
||||
const childRawDeleteDescription = isMultipartContainer
|
||||
? 'Löscht nur die RAW-Quelldatei dieser Disk (nicht die Movie-Subjobs).'
|
||||
: 'Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).';
|
||||
const childMovieDeleteDescription = isMultipartContainer
|
||||
? 'Löscht den encodierten Movie-Output dieser Disk (inkl. Child-/Subjobs).'
|
||||
: 'Löscht die encodierten Folgen dieser Disk (inkl. Child-/Subjobs).';
|
||||
const childBothDeleteDescription = isMultipartContainer
|
||||
? 'Löscht Disk-RAW plus Movie-Output dieser Disk gemeinsam.'
|
||||
: 'Löscht Disk-RAW plus Folgen dieser Disk gemeinsam.';
|
||||
const childCanShowGeneralEncodeActions = childActionState.childCanResumeReady
|
||||
|| typeof onRestartEncode === 'function'
|
||||
|| typeof onRestartReview === 'function'
|
||||
@@ -2413,11 +2442,11 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
disabled={!childActionState.childHasRaw}
|
||||
/>
|
||||
<span className="action-desc">Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).</span>
|
||||
<span className="action-desc">{childRawDeleteDescription}</span>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Folgen löschen (Eintrag bleibt)"
|
||||
label={childOutputDeleteLabel}
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
@@ -2426,7 +2455,7 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
disabled={!childActionState.childHasAnyOutput}
|
||||
/>
|
||||
<span className="action-desc">Löscht die encodierten Folgen dieser Disk (inkl. Child-/Subjobs).</span>
|
||||
<span className="action-desc">{childMovieDeleteDescription}</span>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
@@ -2438,7 +2467,7 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
disabled={!childActionState.childHasRaw && !childActionState.childHasAnyOutput}
|
||||
/>
|
||||
<span className="action-desc">Löscht Disk-RAW plus Folgen dieser Disk gemeinsam.</span>
|
||||
<span className="action-desc">{childBothDeleteDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merging" severity="warning" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mergeState === 'done' || isCompleted) {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Fertig" severity="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mergeVisibleInRipper) {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
@@ -1711,13 +1821,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mergeState === 'done') {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Fertig" severity="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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') => (
|
||||
<span className={`history-dv-chip ${tone}`}>
|
||||
<i className={`pi ${iconClass}`} aria-hidden="true" />
|
||||
<span>{label}: {valueLabel}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderSeriesOutputChip = (row) => {
|
||||
const summary = row?.seriesOutputSummary || null;
|
||||
const label = isMultipartContainerHistoryRow(row) ? 'Movie' : 'Episoden';
|
||||
@@ -1804,6 +1914,36 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return renderPresenceChip('RAW', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
|
||||
};
|
||||
|
||||
const renderMultipartMergeChip = (row) => {
|
||||
if (!isMultipartHistoryRow(row)) {
|
||||
return null;
|
||||
}
|
||||
const mergeSummary = row?.seriesChildSummary?.merge && typeof row.seriesChildSummary.merge === 'object'
|
||||
? row.seriesChildSummary.merge
|
||||
: null;
|
||||
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
|
||||
const isMultipartContainer = isMultipartContainerHistoryRow(row);
|
||||
const rowStatus = String(row?.status || row?.last_state || '').trim().toUpperCase();
|
||||
const mergeRunning = mergeState === 'active' || rowStatus === 'ENCODING';
|
||||
if (mergeRunning) {
|
||||
return renderStateChip('Merge', 'Läuft', 'tone-warn', 'pi-spinner pi-spin');
|
||||
}
|
||||
const isCompleted = Boolean(
|
||||
isMultipartContainer
|
||||
? (mergeSummary?.completed || mergeSummary?.outputExists || mergeState === 'done')
|
||||
: (
|
||||
mergeSummary?.completed
|
||||
|| mergeSummary?.outputExists
|
||||
|| row?.outputStatus?.exists
|
||||
|| rowStatus === 'FINISHED'
|
||||
)
|
||||
);
|
||||
if (isCompleted) {
|
||||
return renderPresenceChip('Merge', true, 'tone-ok');
|
||||
}
|
||||
return renderPresenceChip('Merge', false, 'tone-no');
|
||||
};
|
||||
|
||||
const renderSupplementalInfo = (row) => {
|
||||
if (resolveMediaType(row) === 'cd') {
|
||||
const cdDetails = resolveCdDetails(row);
|
||||
@@ -1901,6 +2041,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
||||
? `TMDb ${selectedMetadata.tmdbId}`
|
||||
: null;
|
||||
const multipartLabel = isMultipartHistoryRow(row)
|
||||
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
|
||||
: null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
@@ -1912,7 +2055,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
`#${row?.id || '-'}`,
|
||||
row?.year || '-',
|
||||
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||
tmdbLabel
|
||||
tmdbLabel,
|
||||
multipartLabel
|
||||
].filter(Boolean).join(' | ');
|
||||
|
||||
return (
|
||||
@@ -1933,7 +2077,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<div className="history-dv-main">
|
||||
<div className="history-dv-head">
|
||||
<div className="history-dv-title-block">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
<strong className="history-dv-title">
|
||||
{isMultipartHistoryRow(row) ? (
|
||||
<img
|
||||
src={mergeIndicatorIcon}
|
||||
alt="Multipart"
|
||||
title="Multipart"
|
||||
className="media-indicator-icon"
|
||||
/>
|
||||
) : null}
|
||||
<span>{row?.title || row?.detected_title || '-'}</span>
|
||||
</strong>
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
</div>
|
||||
{renderStatusTag(row)}
|
||||
@@ -1956,6 +2110,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{renderMultipartMergeChip(row)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
@@ -1985,6 +2140,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
||||
? `TMDb ${selectedMetadata.tmdbId}`
|
||||
: null;
|
||||
const multipartLabel = isMultipartHistoryRow(row)
|
||||
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
|
||||
: null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
@@ -1996,7 +2154,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
`#${row?.id || '-'}`,
|
||||
row?.year || '-',
|
||||
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||
tmdbLabel
|
||||
tmdbLabel,
|
||||
multipartLabel
|
||||
].filter(Boolean).join(' | ');
|
||||
|
||||
return (
|
||||
@@ -2020,7 +2179,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
|
||||
<div className="history-dv-grid-main">
|
||||
<div className="history-dv-head">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
<strong className="history-dv-title">
|
||||
{isMultipartHistoryRow(row) ? (
|
||||
<img
|
||||
src={mergeIndicatorIcon}
|
||||
alt="Multipart"
|
||||
title="Multipart"
|
||||
className="media-indicator-icon"
|
||||
/>
|
||||
) : null}
|
||||
<span>{row?.title || row?.detected_title || '-'}</span>
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
@@ -2042,6 +2211,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{renderMultipartMergeChip(row)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
@@ -2061,9 +2231,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||
const rawDeleteSelectionEnabled = previewRawExisting.length > 2;
|
||||
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 2;
|
||||
const previewRawDisplay = rawDeleteSelectionEnabled
|
||||
? previewRawPaths
|
||||
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
|
||||
@@ -2076,7 +2247,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedMoviePaths]
|
||||
);
|
||||
const rawDeleteSelectionRequired = rawDeleteSelectionEnabled && deleteEntrySelectedRawPaths.length === 0;
|
||||
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||
&& rawDeleteSelectionEnabled
|
||||
&& deleteEntrySelectedRawPaths.length === 0;
|
||||
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
|
||||
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||
@@ -2168,9 +2341,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
onCancel={handleCancelJob}
|
||||
onRestoreMultipartMerge={handleRestoreMultipartMerge}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
cancelBusy={actionBusy}
|
||||
restoreMergeBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
@@ -2200,7 +2375,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||
</p>
|
||||
<small className="history-dv-subtle">
|
||||
Hinweis: In diesem Dialog löschen alle ersten drei Buttons immer den Historien-Eintrag plus ausgewählte Dateien.
|
||||
{deleteEntryIsMergeJob
|
||||
? 'Hinweis: Die Aktion "Eintrag + Merge" löscht den Historien-Eintrag plus ausgewählte Merge-Datei(en).'
|
||||
: 'Hinweis: In diesem Dialog löschen alle ersten drei Buttons immer den Historien-Eintrag plus ausgewählte Dateien.'}
|
||||
</small>
|
||||
|
||||
{deleteEntryDialogRow ? (
|
||||
@@ -2228,6 +2405,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!deleteEntryIsMergeJob ? (
|
||||
<div>
|
||||
<h4>RAW</h4>
|
||||
{previewRawPaths.length > 0 ? (() => {
|
||||
@@ -2268,6 +2446,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||
@@ -2308,6 +2487,18 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
{deleteEntryIsMergeJob ? (
|
||||
<Button
|
||||
label="Eintrag + Merge"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
label="Eintrag + nur RAW-Dateien"
|
||||
icon="pi pi-trash"
|
||||
@@ -2334,6 +2525,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
label="Nur Eintrag löschen"
|
||||
icon="pi pi-database"
|
||||
|
||||
@@ -603,6 +603,11 @@ function extractMultipartMergeSources(job) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function shouldDeleteMultipartMergeInputsAfterSuccess(job) {
|
||||
const encodePlan = getEncodePlan(job) || {};
|
||||
return Boolean(encodePlan?.deleteInputsAfterMerge);
|
||||
}
|
||||
|
||||
function formatMergeTrackLanguage(language) {
|
||||
const normalized = String(language || '').trim().toLowerCase();
|
||||
if (!normalized || normalized === 'und') {
|
||||
@@ -2112,6 +2117,24 @@ export default function RipperPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMultipartMergeSettings = async (jobId, settings = {}) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.updateMultipartMergeSettings(normalizedJobId, settings);
|
||||
await refreshPipeline();
|
||||
await loadRipperJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setJobBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveActivationBytes = async () => {
|
||||
const { checksum, jobId } = activationBytesDialog;
|
||||
const bytes = activationBytesInput.trim().toLowerCase();
|
||||
@@ -3352,14 +3375,17 @@ export default function RipperPage({
|
||||
|| null
|
||||
)
|
||||
: null;
|
||||
const rawProgress = hasSeriesBatchProgress
|
||||
const isCancelledState = normalizedStatus === 'CANCELLED' || jobState === 'CANCELLED';
|
||||
const rawProgress = isCancelledState
|
||||
? 0
|
||||
: (hasSeriesBatchProgress
|
||||
? seriesBatchAggregateProgress
|
||||
: Number(pipelineForJob?.progress ?? 0);
|
||||
: Number(pipelineForJob?.progress ?? 0));
|
||||
const clampedProgress = Number.isFinite(rawProgress)
|
||||
? Math.max(0, Math.min(100, rawProgress))
|
||||
: 0;
|
||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||
const etaLabel = String(
|
||||
const etaLabel = isCancelledState ? '' : String(
|
||||
hasSeriesBatchProgress
|
||||
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
||||
: (pipelineForJob?.eta || '')
|
||||
@@ -3382,6 +3408,12 @@ export default function RipperPage({
|
||||
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
||||
);
|
||||
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
|
||||
const deleteInputsAfterMerge = isMultipartMerge
|
||||
? shouldDeleteMultipartMergeInputsAfterSuccess(job)
|
||||
: false;
|
||||
const canEditMultipartMergeSettings = !busyJobIds.has(jobId)
|
||||
&& !processingStates.includes(normalizedStatus)
|
||||
&& !isQueued;
|
||||
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
||||
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||
? multipartMergePreview.chapters.entries
|
||||
@@ -3677,6 +3709,26 @@ export default function RipperPage({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMultipartMergeRunning ? (
|
||||
<div className="multipart-merge-settings">
|
||||
<label className="multipart-merge-setting-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteInputsAfterMerge}
|
||||
onChange={(event) => {
|
||||
void handleUpdateMultipartMergeSettings(jobId, {
|
||||
deleteInputsAfterMerge: Boolean(event.target?.checked)
|
||||
});
|
||||
}}
|
||||
disabled={!canEditMultipartMergeSettings}
|
||||
/>
|
||||
<span>Input-Dateien nach erfolgreichem Merge löschen</span>
|
||||
</label>
|
||||
<small className="muted-inline">
|
||||
Bei Erfolg werden die Quell-Dateien der Discs aus dem Dateisystem entfernt.
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="multipart-merge-actions">
|
||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user