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(
|
router.get(
|
||||||
'/multipart-merge/:jobId/preview',
|
'/multipart-merge/:jobId/preview',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -563,6 +563,22 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
|||||||
) {
|
) {
|
||||||
return 'dvd';
|
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';
|
return profileHint || 'other';
|
||||||
}
|
}
|
||||||
@@ -706,6 +722,40 @@ function getSeriesRawPathCandidates(settings = {}) {
|
|||||||
return unique;
|
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 = {}) {
|
function isLikelySeriesRawPath(rawPath, settings = {}) {
|
||||||
const normalizedRawPath = normalizeComparablePath(rawPath);
|
const normalizedRawPath = normalizeComparablePath(rawPath);
|
||||||
if (!normalizedRawPath) {
|
if (!normalizedRawPath) {
|
||||||
@@ -862,6 +912,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
|||||||
const effectiveRawPath = job?.raw_path
|
const effectiveRawPath = job?.raw_path
|
||||||
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
||||||
: (job?.raw_path || null);
|
: (job?.raw_path || null);
|
||||||
|
rawDir = resolveRawRootForPath(settings || {}, rawDir, effectiveRawPath);
|
||||||
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc)
|
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc)
|
||||||
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
||||||
: (job?.output_path || null);
|
: (job?.output_path || null);
|
||||||
@@ -6776,7 +6827,9 @@ class HistoryService {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!isPathInside(effectiveRawDir, effectiveRawPath)) {
|
} 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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(effectiveRawPath)) {
|
} else if (!fs.existsSync(effectiveRawPath)) {
|
||||||
@@ -6864,7 +6917,9 @@ class HistoryService {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!isPathInside(effectiveMovieDir, effectiveOutputPath)) {
|
} 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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(effectiveOutputPath)) {
|
} else if (!fs.existsSync(effectiveOutputPath)) {
|
||||||
|
|||||||
@@ -6017,6 +6017,33 @@ function isPathInsideDirectory(parentPath, candidatePath) {
|
|||||||
return candidate.startsWith(parentWithSep);
|
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) {
|
function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) {
|
||||||
const sourceInputPath = String(inputPath || '').trim();
|
const sourceInputPath = String(inputPath || '').trim();
|
||||||
if (!sourceInputPath) {
|
if (!sourceInputPath) {
|
||||||
@@ -11597,6 +11624,7 @@ class PipelineService extends EventEmitter {
|
|||||||
orderedSourceJobIds,
|
orderedSourceJobIds,
|
||||||
sourceItems,
|
sourceItems,
|
||||||
mergeOutputPath,
|
mergeOutputPath,
|
||||||
|
deleteInputsAfterMerge: Boolean(existingMergePlan?.deleteInputsAfterMerge),
|
||||||
mergeStrategy: 'mkvmerge_append',
|
mergeStrategy: 'mkvmerge_append',
|
||||||
updatedAt: nowIso()
|
updatedAt: nowIso()
|
||||||
};
|
};
|
||||||
@@ -11775,6 +11803,131 @@ class PipelineService extends EventEmitter {
|
|||||||
return historyService.getJobById(normalizedJobId);
|
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 = {}) {
|
async restoreMultipartMergeJobForContainer(containerJobId, options = {}) {
|
||||||
const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId);
|
const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId);
|
||||||
if (!normalizedContainerJobId) {
|
if (!normalizedContainerJobId) {
|
||||||
@@ -12033,6 +12186,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {};
|
const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {};
|
||||||
const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan);
|
const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan);
|
||||||
|
const deleteInputsAfterMerge = Boolean(mergePlan?.deleteInputsAfterMerge);
|
||||||
if (sourceItems.length < 2) {
|
if (sourceItems.length < 2) {
|
||||||
const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.');
|
const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.');
|
||||||
error.statusCode = 409;
|
error.statusCode = 409;
|
||||||
@@ -12075,6 +12229,7 @@ class PipelineService extends EventEmitter {
|
|||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
mode: 'multipart_merge',
|
mode: 'multipart_merge',
|
||||||
mediaProfile,
|
mediaProfile,
|
||||||
|
deleteInputsAfterMerge,
|
||||||
outputPath: incompleteOutputPath,
|
outputPath: incompleteOutputPath,
|
||||||
sourceItems
|
sourceItems
|
||||||
}
|
}
|
||||||
@@ -12094,168 +12249,201 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
|
const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
|
||||||
let chaptersFilePath = null;
|
void (async () => {
|
||||||
|
let chaptersFilePath = null;
|
||||||
let runInfo = null;
|
let runInfo = null;
|
||||||
try {
|
try {
|
||||||
let chapterPreparationMode = 'source_native';
|
let chapterPreparationMode = 'source_native';
|
||||||
let chapterPreparationEntries = 0;
|
let chapterPreparationEntries = 0;
|
||||||
const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand });
|
const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand });
|
||||||
const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses)
|
const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses)
|
||||||
? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error))
|
? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error))
|
||||||
: [];
|
: [];
|
||||||
if (erroredSources.length > 0) {
|
if (erroredSources.length > 0) {
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
normalizedJobId,
|
normalizedJobId,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
`Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).`
|
`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('+');
|
|
||||||
}
|
}
|
||||||
|
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) {
|
if (chaptersFilePath) {
|
||||||
mkvmergeArgs.push('--no-chapters');
|
mkvmergeArgs.push('--chapters', chaptersFilePath);
|
||||||
}
|
}
|
||||||
mkvmergeArgs.push(item.outputPath);
|
sourceItems.forEach((item, sourceIndex) => {
|
||||||
});
|
if (sourceIndex > 0) {
|
||||||
|
mkvmergeArgs.push('+');
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
if (chaptersFilePath) {
|
||||||
|
mkvmergeArgs.push('--no-chapters');
|
||||||
|
}
|
||||||
|
mkvmergeArgs.push(item.outputPath);
|
||||||
});
|
});
|
||||||
setTimeout(async () => {
|
|
||||||
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedJobId) {
|
runInfo = await this.runCommand({
|
||||||
await this.setState('IDLE', {
|
jobId: normalizedJobId,
|
||||||
finishingJobId: normalizedJobId,
|
stage: 'ENCODING',
|
||||||
activeJobId: null,
|
source: 'MKVMERGE',
|
||||||
progress: 0,
|
cmd: mkvmergeCommand,
|
||||||
eta: null,
|
args: mkvmergeArgs,
|
||||||
statusText: 'Bereit',
|
parser: parseMkvmergeProgress
|
||||||
context: {}
|
});
|
||||||
|
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();
|
const handbrakeInfo = {
|
||||||
}
|
...runInfo,
|
||||||
void this.notifyPushover('job_finished', {
|
mode: 'multipart_merge',
|
||||||
title: 'Ripster - Merge abgeschlossen',
|
tool: 'mkvmerge',
|
||||||
message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}`
|
chapterPreparation: {
|
||||||
});
|
mode: chapterPreparationMode,
|
||||||
return {
|
chapterCount: chapterPreparationEntries
|
||||||
started: true,
|
},
|
||||||
stage: 'ENCODING',
|
sourceItems,
|
||||||
outputPath: finalizedOutputPath
|
sourceCleanup: sourceCleanupSummary,
|
||||||
};
|
outputPath: finalizedOutputPath
|
||||||
} catch (error) {
|
};
|
||||||
if (runInfo) {
|
|
||||||
await historyService.updateJob(normalizedJobId, {
|
await historyService.updateJob(normalizedJobId, {
|
||||||
handbrake_info_json: JSON.stringify({
|
handbrake_info_json: JSON.stringify(handbrakeInfo),
|
||||||
...runInfo,
|
status: 'FINISHED',
|
||||||
mode: 'multipart_merge',
|
last_state: 'FINISHED',
|
||||||
sourceItems
|
end_time: nowIso(),
|
||||||
})
|
rip_successful: 1,
|
||||||
}).catch(() => {});
|
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;
|
return {
|
||||||
} finally {
|
started: true,
|
||||||
try {
|
stage: 'ENCODING',
|
||||||
fs.rmSync(mergeTmpDir, { recursive: true, force: true });
|
outputPath: incompleteOutputPath
|
||||||
} catch (_error) {
|
};
|
||||||
// ignore tmp cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSeriesBatchChildJob(jobLike = null) {
|
isSeriesBatchChildJob(jobLike = null) {
|
||||||
@@ -23978,7 +24166,7 @@ class PipelineService extends EventEmitter {
|
|||||||
async retry(jobId, options = {}) {
|
async retry(jobId, options = {}) {
|
||||||
const immediate = Boolean(options?.immediate);
|
const immediate = Boolean(options?.immediate);
|
||||||
if (!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 });
|
return this.retry(jobId, { ...options, immediate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24001,6 +24189,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||||||
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
||||||
|
const sourceJobKind = this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan });
|
||||||
const mediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
const mediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
||||||
makemkvInfo: sourceMakemkvInfo,
|
makemkvInfo: sourceMakemkvInfo,
|
||||||
encodePlan: sourceEncodePlan
|
encodePlan: sourceEncodePlan
|
||||||
@@ -24011,6 +24200,39 @@ class PipelineService extends EventEmitter {
|
|||||||
);
|
);
|
||||||
const isCdRetry = mediaProfile === 'cd';
|
const isCdRetry = mediaProfile === 'cd';
|
||||||
const isAudiobookRetry = mediaProfile === 'audiobook';
|
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.
|
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
|
||||||
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
||||||
@@ -24108,7 +24330,7 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
||||||
};
|
};
|
||||||
} // end else (sourceTracks.length > 0)
|
} // end else (sourceTracks.length > 0)
|
||||||
} else if (!isAudiobookRetry) {
|
} else if (!isAudiobookRetry && !isMultipartMergeRetry) {
|
||||||
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
||||||
retrySettings,
|
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({
|
const retryJob = await historyService.createJob({
|
||||||
discDevice: sourceJob.disc_device || null,
|
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,
|
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
|
||||||
jobKind: this.resolveJobKindForJob(sourceJob, {
|
jobKind: this.resolveJobKindForJob(sourceJob, {
|
||||||
encodePlan: sourceEncodePlan
|
encodePlan: sourceEncodePlan
|
||||||
@@ -24215,7 +24459,7 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retryUpdatePayload = {
|
const retryUpdatePayload = {
|
||||||
parent_job_id: Number(jobId),
|
parent_job_id: retryParentJobId ? Number(retryParentJobId) : null,
|
||||||
media_type: preservedRetryMediaType,
|
media_type: preservedRetryMediaType,
|
||||||
title: sourceJob.title || null,
|
title: sourceJob.title || null,
|
||||||
year: sourceJob.year ?? null,
|
year: sourceJob.year ?? null,
|
||||||
@@ -24229,15 +24473,13 @@ class PipelineService extends EventEmitter {
|
|||||||
end_time: null,
|
end_time: null,
|
||||||
handbrake_info_json: null,
|
handbrake_info_json: null,
|
||||||
mediainfo_info_json: null,
|
mediainfo_info_json: null,
|
||||||
encode_plan_json: (isCdRetry || isAudiobookRetry)
|
encode_plan_json: retryEncodePlanJson,
|
||||||
? (sourceJob.encode_plan_json || null)
|
|
||||||
: null,
|
|
||||||
encode_input_path: retryEncodeInputPath,
|
encode_input_path: retryEncodeInputPath,
|
||||||
encode_review_confirmed: isAudiobookRetry ? 1 : 0,
|
encode_review_confirmed: isAudiobookRetry ? 1 : 0,
|
||||||
output_path: null,
|
output_path: null,
|
||||||
raw_path: retryRawPath,
|
raw_path: retryRawPath,
|
||||||
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
|
status: retryInitialStatus,
|
||||||
last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING')
|
last_state: retryInitialStatus
|
||||||
};
|
};
|
||||||
await historyService.updateJob(retryJobId, retryUpdatePayload);
|
await historyService.updateJob(retryJobId, retryUpdatePayload);
|
||||||
|
|
||||||
@@ -24252,14 +24494,16 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
retryJobId,
|
retryJobId,
|
||||||
'USER_ACTION',
|
'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) {
|
if (preservedRetryMediaType && !explicitSourceMediaType) {
|
||||||
await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {});
|
await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {});
|
||||||
}
|
}
|
||||||
this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' });
|
this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' });
|
||||||
await historyService.retireJobInFavorOf(jobId, retryJobId, {
|
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);
|
this.cancelRequestedByJob.delete(retryJobId);
|
||||||
|
|
||||||
@@ -24280,6 +24524,14 @@ class PipelineService extends EventEmitter {
|
|||||||
queued: false,
|
queued: false,
|
||||||
stage: 'READY_TO_START'
|
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 {
|
} else {
|
||||||
this.startRipEncode(retryJobId).catch((error) => {
|
this.startRipEncode(retryJobId).catch((error) => {
|
||||||
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
|
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
|
||||||
@@ -25969,6 +26221,29 @@ class PipelineService extends EventEmitter {
|
|||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
`${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}`
|
`${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 jobProgressContext = this.jobProgress.get(Number(jobId))?.context;
|
||||||
const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
? makemkvInfo.selectedMetadata
|
? makemkvInfo.selectedMetadata
|
||||||
|
|||||||
@@ -650,6 +650,16 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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) {
|
getMultipartMergePreview(jobId) {
|
||||||
return request(`/pipeline/multipart-merge/${jobId}/preview`);
|
return request(`/pipeline/multipart-merge/${jobId}/preview`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Tag } from 'primereact/tag';
|
|||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
|
import mergeIndicatorIcon from '../assets/media-merge.svg';
|
||||||
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
||||||
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
|
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
|
||||||
|
|
||||||
@@ -1233,6 +1234,14 @@ export default function JobDetailDialog({
|
|||||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||||
const isMultipartContainer = jobKindRaw === 'multipart_movie_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 isDiskContainer = isSeriesContainer || isMultipartContainer;
|
||||||
const allChildJobs = Array.isArray(job?.children)
|
const allChildJobs = Array.isArray(job?.children)
|
||||||
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||||
@@ -1446,10 +1455,18 @@ export default function JobDetailDialog({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
header={`Job #${job?.id || ''}`}
|
header={dialogHeader}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
style={{ width: '70rem', maxWidth: '96vw' }}
|
style={{ width: '70rem', maxWidth: '96vw' }}
|
||||||
@@ -2301,6 +2318,18 @@ export default function JobDetailDialog({
|
|||||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||||
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
|
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
|
||||||
const childActionState = resolveChildActionState(child);
|
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
|
const childCanShowGeneralEncodeActions = childActionState.childCanResumeReady
|
||||||
|| typeof onRestartEncode === 'function'
|
|| typeof onRestartEncode === 'function'
|
||||||
|| typeof onRestartReview === 'function'
|
|| typeof onRestartReview === 'function'
|
||||||
@@ -2413,11 +2442,11 @@ export default function JobDetailDialog({
|
|||||||
loading={actionBusy}
|
loading={actionBusy}
|
||||||
disabled={!childActionState.childHasRaw}
|
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>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
<Button
|
<Button
|
||||||
label="Folgen löschen (Eintrag bleibt)"
|
label={childOutputDeleteLabel}
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
severity="warning"
|
severity="warning"
|
||||||
outlined
|
outlined
|
||||||
@@ -2426,7 +2455,7 @@ export default function JobDetailDialog({
|
|||||||
loading={actionBusy}
|
loading={actionBusy}
|
||||||
disabled={!childActionState.childHasAnyOutput}
|
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>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
<Button
|
<Button
|
||||||
@@ -2438,7 +2467,7 @@ export default function JobDetailDialog({
|
|||||||
loading={actionBusy}
|
loading={actionBusy}
|
||||||
disabled={!childActionState.childHasRaw && !childActionState.childHasAnyOutput}
|
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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ReencodeConflictModal from '../components/ReencodeConflictModal';
|
|||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
|
import mergeIndicatorIcon from '../assets/media-merge.svg';
|
||||||
import {
|
import {
|
||||||
getStatusLabel,
|
getStatusLabel,
|
||||||
getStatusSeverity,
|
getStatusSeverity,
|
||||||
@@ -213,6 +214,23 @@ function isMultipartMergeHistoryRow(row) {
|
|||||||
return kind === 'multipart_movie_merge';
|
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) {
|
function isSeriesChildHistoryRow(row) {
|
||||||
const kind = String(
|
const kind = String(
|
||||||
row?.job_kind
|
row?.job_kind
|
||||||
@@ -361,6 +379,9 @@ function getOutputLabelForRow(row) {
|
|||||||
if (isMultipartMergeHistoryRow(row)) {
|
if (isMultipartMergeHistoryRow(row)) {
|
||||||
return 'Merge-Datei(en)';
|
return 'Merge-Datei(en)';
|
||||||
}
|
}
|
||||||
|
if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) {
|
||||||
|
return 'Movie-Datei(en)';
|
||||||
|
}
|
||||||
if (isSeriesVideoJob(row)) {
|
if (isSeriesVideoJob(row)) {
|
||||||
return 'Folgen-Datei(en)';
|
return 'Folgen-Datei(en)';
|
||||||
}
|
}
|
||||||
@@ -377,6 +398,9 @@ function getOutputShortLabelForRow(row) {
|
|||||||
if (isMultipartMergeHistoryRow(row)) {
|
if (isMultipartMergeHistoryRow(row)) {
|
||||||
return 'Merge';
|
return 'Merge';
|
||||||
}
|
}
|
||||||
|
if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) {
|
||||||
|
return 'Movie';
|
||||||
|
}
|
||||||
if (isSeriesVideoJob(row)) {
|
if (isSeriesVideoJob(row)) {
|
||||||
return 'Folgen';
|
return 'Folgen';
|
||||||
}
|
}
|
||||||
@@ -958,6 +982,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const handleDeleteFiles = async (row, target) => {
|
const handleDeleteFiles = async (row, target) => {
|
||||||
const isContainerRow = isContainerHistoryRow(row);
|
const isContainerRow = isContainerHistoryRow(row);
|
||||||
const isMultipartContainerRow = isMultipartContainerHistoryRow(row);
|
const isMultipartContainerRow = isMultipartContainerHistoryRow(row);
|
||||||
|
const isMultipartChildRow = isMultipartChildHistoryRow(row);
|
||||||
const isSeriesChildRow = isSeriesChildHistoryRow(row);
|
const isSeriesChildRow = isSeriesChildHistoryRow(row);
|
||||||
const includeRelated = isContainerRow
|
const includeRelated = isContainerRow
|
||||||
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
|
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
|
||||||
@@ -981,7 +1006,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
: isSeriesChildRow && target === 'raw'
|
: isSeriesChildRow && target === 'raw'
|
||||||
? '\nScope: nur RAW dieses Disk-Jobs.'
|
? '\nScope: nur RAW dieses Disk-Jobs.'
|
||||||
: isSeriesChildRow && (target === 'movie' || target === 'both')
|
: 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
|
: includeRelated
|
||||||
? '\nScope: verknüpfte Jobs werden einbezogen.'
|
? '\nScope: verknüpfte Jobs werden einbezogen.'
|
||||||
: '';
|
: '';
|
||||||
@@ -1509,6 +1536,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||||
|
if (isMergeJob && (normalizedTarget === 'raw' || normalizedTarget === 'both')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
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 handleRestoreMultipartMerge = async (row) => {
|
||||||
const containerJobId = normalizeJobId(row?.id || row);
|
const containerJobId = normalizeJobId(row?.id || row);
|
||||||
if (!containerJobId) {
|
if (!containerJobId) {
|
||||||
@@ -1685,10 +1780,25 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
: null;
|
: null;
|
||||||
if (mergeSummary) {
|
if (mergeSummary) {
|
||||||
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
|
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
|
||||||
|
const mergeActive = Boolean(mergeSummary?.active) || mergeState === 'active';
|
||||||
const hasMergeJob = Boolean(mergeSummary?.hasJob);
|
const hasMergeJob = Boolean(mergeSummary?.hasJob);
|
||||||
const isCompleted = Boolean(mergeSummary?.completed);
|
const isCompleted = Boolean(mergeSummary?.completed);
|
||||||
const filesReady = Boolean(mergeSummary?.ready);
|
const filesReady = Boolean(mergeSummary?.ready);
|
||||||
const mergeVisibleInRipper = hasMergeJob && !isCompleted;
|
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) {
|
if (mergeVisibleInRipper) {
|
||||||
return (
|
return (
|
||||||
<div className="history-status-tag-wrap">
|
<div className="history-status-tag-wrap">
|
||||||
@@ -1711,13 +1821,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (mergeState === 'done') {
|
|
||||||
return (
|
|
||||||
<div className="history-status-tag-wrap">
|
|
||||||
<Tag value="Fertig" severity="success" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isMultipartMergeHistoryRow(row) && normalizeStatus(row?.status) === 'ENCODING') {
|
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 renderSeriesOutputChip = (row) => {
|
||||||
const summary = row?.seriesOutputSummary || null;
|
const summary = row?.seriesOutputSummary || null;
|
||||||
const label = isMultipartContainerHistoryRow(row) ? 'Movie' : 'Episoden';
|
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})`);
|
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) => {
|
const renderSupplementalInfo = (row) => {
|
||||||
if (resolveMediaType(row) === 'cd') {
|
if (resolveMediaType(row) === 'cd') {
|
||||||
const cdDetails = resolveCdDetails(row);
|
const cdDetails = resolveCdDetails(row);
|
||||||
@@ -1901,6 +2041,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
||||||
? `TMDb ${selectedMetadata.tmdbId}`
|
? `TMDb ${selectedMetadata.tmdbId}`
|
||||||
: null;
|
: null;
|
||||||
|
const multipartLabel = isMultipartHistoryRow(row)
|
||||||
|
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
|
||||||
|
: null;
|
||||||
const subtitle = isCdJob
|
const subtitle = isCdJob
|
||||||
? [
|
? [
|
||||||
`#${row?.id || '-'}`,
|
`#${row?.id || '-'}`,
|
||||||
@@ -1912,7 +2055,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
`#${row?.id || '-'}`,
|
`#${row?.id || '-'}`,
|
||||||
row?.year || '-',
|
row?.year || '-',
|
||||||
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||||
tmdbLabel
|
tmdbLabel,
|
||||||
|
multipartLabel
|
||||||
].filter(Boolean).join(' | ');
|
].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1933,7 +2077,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
<div className="history-dv-main">
|
<div className="history-dv-main">
|
||||||
<div className="history-dv-head">
|
<div className="history-dv-head">
|
||||||
<div className="history-dv-title-block">
|
<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>
|
<small className="history-dv-subtle">{subtitle}</small>
|
||||||
</div>
|
</div>
|
||||||
{renderStatusTag(row)}
|
{renderStatusTag(row)}
|
||||||
@@ -1956,6 +2110,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
? renderSeriesOutputChip(row)
|
? renderSeriesOutputChip(row)
|
||||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
{renderMultipartMergeChip(row)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(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
|
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
||||||
? `TMDb ${selectedMetadata.tmdbId}`
|
? `TMDb ${selectedMetadata.tmdbId}`
|
||||||
: null;
|
: null;
|
||||||
|
const multipartLabel = isMultipartHistoryRow(row)
|
||||||
|
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
|
||||||
|
: null;
|
||||||
const subtitle = isCdJob
|
const subtitle = isCdJob
|
||||||
? [
|
? [
|
||||||
`#${row?.id || '-'}`,
|
`#${row?.id || '-'}`,
|
||||||
@@ -1996,7 +2154,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
`#${row?.id || '-'}`,
|
`#${row?.id || '-'}`,
|
||||||
row?.year || '-',
|
row?.year || '-',
|
||||||
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||||
tmdbLabel
|
tmdbLabel,
|
||||||
|
multipartLabel
|
||||||
].filter(Boolean).join(' | ');
|
].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2020,7 +2179,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
<div className="history-dv-grid-main">
|
<div className="history-dv-grid-main">
|
||||||
<div className="history-dv-head">
|
<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>
|
</div>
|
||||||
|
|
||||||
<small className="history-dv-subtle">{subtitle}</small>
|
<small className="history-dv-subtle">{subtitle}</small>
|
||||||
@@ -2042,6 +2211,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
? renderSeriesOutputChip(row)
|
? renderSeriesOutputChip(row)
|
||||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
{renderMultipartMergeChip(row)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(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 previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||||
|
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||||
const previewMovieExisting = previewMoviePaths.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
|
const previewRawDisplay = rawDeleteSelectionEnabled
|
||||||
? previewRawPaths
|
? previewRawPaths
|
||||||
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
|
: (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)),
|
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||||
[deleteEntrySelectedMoviePaths]
|
[deleteEntrySelectedMoviePaths]
|
||||||
);
|
);
|
||||||
const rawDeleteSelectionRequired = rawDeleteSelectionEnabled && deleteEntrySelectedRawPaths.length === 0;
|
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||||
|
&& rawDeleteSelectionEnabled
|
||||||
|
&& deleteEntrySelectedRawPaths.length === 0;
|
||||||
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
|
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
|
||||||
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||||
@@ -2168,9 +2341,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onDownloadArchive={handleDownloadArchive}
|
onDownloadArchive={handleDownloadArchive}
|
||||||
onDownloadOutputFolder={handleDownloadOutputFolder}
|
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
|
onCancel={handleCancelJob}
|
||||||
onRestoreMultipartMerge={handleRestoreMultipartMerge}
|
onRestoreMultipartMerge={handleRestoreMultipartMerge}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
|
cancelBusy={actionBusy}
|
||||||
restoreMergeBusy={actionBusy}
|
restoreMergeBusy={actionBusy}
|
||||||
omdbAssignBusy={omdbAssignBusy}
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
@@ -2200,7 +2375,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||||
</p>
|
</p>
|
||||||
<small className="history-dv-subtle">
|
<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>
|
</small>
|
||||||
|
|
||||||
{deleteEntryDialogRow ? (
|
{deleteEntryDialogRow ? (
|
||||||
@@ -2228,46 +2405,48 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{!deleteEntryIsMergeJob ? (
|
||||||
<h4>RAW</h4>
|
<div>
|
||||||
{previewRawPaths.length > 0 ? (() => {
|
<h4>RAW</h4>
|
||||||
return (
|
{previewRawPaths.length > 0 ? (() => {
|
||||||
<ul className="history-delete-preview-list">
|
return (
|
||||||
{previewRawDisplay.map((item) => (
|
<ul className="history-delete-preview-list">
|
||||||
<li key={`delete-raw-${item.path}`}>
|
{previewRawDisplay.map((item) => (
|
||||||
{rawDeleteSelectionEnabled && item.exists ? (
|
<li key={`delete-raw-${item.path}`}>
|
||||||
<label className="history-delete-preview-checkbox-row">
|
{rawDeleteSelectionEnabled && item.exists ? (
|
||||||
<input
|
<label className="history-delete-preview-checkbox-row">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={selectedDeleteRawPathSet.has(String(item.path || '').trim())}
|
type="checkbox"
|
||||||
onChange={(event) => toggleDeleteRawPathSelection(item.path, Boolean(event.target.checked))}
|
checked={selectedDeleteRawPathSet.has(String(item.path || '').trim())}
|
||||||
/>
|
onChange={(event) => toggleDeleteRawPathSelection(item.path, Boolean(event.target.checked))}
|
||||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
/>
|
||||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||||
</span>
|
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||||
<span>| {item.path}</span>
|
</span>
|
||||||
</label>
|
<span>| {item.path}</span>
|
||||||
) : (
|
</label>
|
||||||
<>
|
) : (
|
||||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
<>
|
||||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||||
</span>
|
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||||
{' '}| {item.path}
|
</span>
|
||||||
</>
|
{' '}| {item.path}
|
||||||
)}
|
</>
|
||||||
</li>
|
)}
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
);
|
</ul>
|
||||||
})() : (
|
);
|
||||||
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
|
})() : (
|
||||||
)}
|
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
|
||||||
{rawDeleteSelectionEnabled ? (
|
)}
|
||||||
<small className="history-dv-subtle">
|
{rawDeleteSelectionEnabled ? (
|
||||||
RAW-Auswahl aktiv: {deleteEntrySelectedRawPaths.length}/{previewRawExisting.length} ausgewählt.
|
<small className="history-dv-subtle">
|
||||||
</small>
|
RAW-Auswahl aktiv: {deleteEntrySelectedRawPaths.length}/{previewRawExisting.length} ausgewählt.
|
||||||
) : null}
|
</small>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||||
@@ -2308,32 +2487,46 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<Button
|
{deleteEntryIsMergeJob ? (
|
||||||
label="Eintrag + nur RAW-Dateien"
|
<Button
|
||||||
icon="pi pi-trash"
|
label="Eintrag + Merge"
|
||||||
severity="warning"
|
icon="pi pi-trash"
|
||||||
outlined
|
severity="warning"
|
||||||
onClick={() => confirmDeleteEntry('raw')}
|
outlined
|
||||||
loading={deleteEntryTargetBusy === 'raw'}
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
/>
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||||
<Button
|
/>
|
||||||
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
) : (
|
||||||
icon="pi pi-trash"
|
<>
|
||||||
severity="warning"
|
<Button
|
||||||
outlined
|
label="Eintrag + nur RAW-Dateien"
|
||||||
onClick={() => confirmDeleteEntry('movie')}
|
icon="pi pi-trash"
|
||||||
loading={deleteEntryTargetBusy === 'movie'}
|
severity="warning"
|
||||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
outlined
|
||||||
/>
|
onClick={() => confirmDeleteEntry('raw')}
|
||||||
<Button
|
loading={deleteEntryTargetBusy === 'raw'}
|
||||||
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
||||||
icon="pi pi-times"
|
/>
|
||||||
severity="danger"
|
<Button
|
||||||
onClick={() => confirmDeleteEntry('both')}
|
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
||||||
loading={deleteEntryTargetBusy === 'both'}
|
icon="pi pi-trash"
|
||||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
severity="warning"
|
||||||
/>
|
outlined
|
||||||
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
onClick={() => confirmDeleteEntry('both')}
|
||||||
|
loading={deleteEntryTargetBusy === 'both'}
|
||||||
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
label="Nur Eintrag löschen"
|
label="Nur Eintrag löschen"
|
||||||
icon="pi pi-database"
|
icon="pi pi-database"
|
||||||
|
|||||||
@@ -603,6 +603,11 @@ function extractMultipartMergeSources(job) {
|
|||||||
return ordered;
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldDeleteMultipartMergeInputsAfterSuccess(job) {
|
||||||
|
const encodePlan = getEncodePlan(job) || {};
|
||||||
|
return Boolean(encodePlan?.deleteInputsAfterMerge);
|
||||||
|
}
|
||||||
|
|
||||||
function formatMergeTrackLanguage(language) {
|
function formatMergeTrackLanguage(language) {
|
||||||
const normalized = String(language || '').trim().toLowerCase();
|
const normalized = String(language || '').trim().toLowerCase();
|
||||||
if (!normalized || normalized === 'und') {
|
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 handleSaveActivationBytes = async () => {
|
||||||
const { checksum, jobId } = activationBytesDialog;
|
const { checksum, jobId } = activationBytesDialog;
|
||||||
const bytes = activationBytesInput.trim().toLowerCase();
|
const bytes = activationBytesInput.trim().toLowerCase();
|
||||||
@@ -3352,14 +3375,17 @@ export default function RipperPage({
|
|||||||
|| null
|
|| null
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const rawProgress = hasSeriesBatchProgress
|
const isCancelledState = normalizedStatus === 'CANCELLED' || jobState === 'CANCELLED';
|
||||||
|
const rawProgress = isCancelledState
|
||||||
|
? 0
|
||||||
|
: (hasSeriesBatchProgress
|
||||||
? seriesBatchAggregateProgress
|
? seriesBatchAggregateProgress
|
||||||
: Number(pipelineForJob?.progress ?? 0);
|
: Number(pipelineForJob?.progress ?? 0));
|
||||||
const clampedProgress = Number.isFinite(rawProgress)
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
? Math.max(0, Math.min(100, rawProgress))
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
: 0;
|
: 0;
|
||||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||||
const etaLabel = String(
|
const etaLabel = isCancelledState ? '' : String(
|
||||||
hasSeriesBatchProgress
|
hasSeriesBatchProgress
|
||||||
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
||||||
: (pipelineForJob?.eta || '')
|
: (pipelineForJob?.eta || '')
|
||||||
@@ -3382,6 +3408,12 @@ export default function RipperPage({
|
|||||||
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
||||||
);
|
);
|
||||||
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
|
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 multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
||||||
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||||
? multipartMergePreview.chapters.entries
|
? multipartMergePreview.chapters.entries
|
||||||
@@ -3677,6 +3709,26 @@ export default function RipperPage({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="multipart-merge-actions">
|
||||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user