diff --git a/backend/package-lock.json b/backend/package-lock.json index 3f219c7..074813a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.14.0-4", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.14.0-4", + "version": "0.15.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index f467cc1..4de08c2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.14.0-4", + "version": "0.15.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index d384f7a..f32a738 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -1015,6 +1015,12 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('mkvmerge_command', 'Tools', 'mkvmerge Kommando', 'string', 1, 'Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt.', 'mkvmerge', '[]', '{"minLength":1}', 2325)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mkvmerge_command', 'mkvmerge')`); + await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)` diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 09e46d9..067d4a6 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -455,6 +455,62 @@ router.post( }) ); +router.post( + '/multipart-merge/:jobId/reorder', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds) + ? req.body.orderedSourceJobIds + : []; + logger.info('post:multipart-merge:reorder', { + reqId: req.reqId, + jobId, + orderedCount: orderedSourceJobIds.length + }); + const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds); + res.json({ job }); + }) +); + +router.get( + '/multipart-merge/:jobId/preview', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('get:multipart-merge:preview', { + reqId: req.reqId, + jobId + }); + const preview = await pipelineService.getMultipartMergePreview(jobId); + res.json({ preview }); + }) +); + +router.post( + '/multipart-merge/:containerJobId/restore', + asyncHandler(async (req, res) => { + const containerJobId = Number(req.params.containerJobId); + logger.info('post:multipart-merge:restore', { + reqId: req.reqId, + containerJobId + }); + const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId); + await pipelineService.emitQueueChanged().catch((error) => { + logger.warn('post:multipart-merge:restore:queue-emit-failed', { + reqId: req.reqId, + containerJobId, + error: error?.message || String(error) + }); + }); + res.json({ + result: { + restored: true, + mergeJobId: Number(job?.id || 0) || null + }, + job + }); + }) +); + router.post( '/confirm-encode/:jobId', asyncHandler(async (req, res) => { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index fcc7290..948cfdd 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -394,6 +394,7 @@ function normalizeJobKindValue(value) { || raw === 'dvd_series_child' || raw === 'multipart_movie_container' || raw === 'multipart_movie_child' + || raw === 'multipart_movie_merge' ) { return raw; } @@ -2474,10 +2475,37 @@ function isSeriesContainerRow(row) { return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; } +function isMultipartContainerRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'; +} + +function isDiskContainerRow(row) { + return isSeriesContainerRow(row) || isMultipartContainerRow(row); +} + function isSeriesChildRow(row) { return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_child'; } +function isMultipartChildRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child'; +} + +function isMultipartMergeRow(row) { + const jobKind = String(row?.job_kind || '').trim().toLowerCase(); + if (jobKind === 'multipart_movie_merge') { + return true; + } + const encodePlan = parseInfoFromValue(row?.encodePlan ?? row?.encode_plan_json, null); + const handbrakeInfo = parseInfoFromValue(row?.handbrakeInfo ?? row?.handbrake_info_json, null); + const planJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase(); + if (planJobKind === 'multipart_movie_merge') { + return true; + } + const mode = String(encodePlan?.mode || handbrakeInfo?.mode || '').trim().toLowerCase(); + return mode === 'multipart_merge'; +} + function normalizeArchiveTarget(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'raw') { @@ -3866,7 +3894,7 @@ class HistoryService { return job; }); - const containerJobs = adjustedJobs.filter((job) => String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'); + const containerJobs = adjustedJobs.filter((job) => isDiskContainerRow(job)); const containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); const seriesCandidates = adjustedJobs.filter((job) => { const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); @@ -3983,13 +4011,20 @@ class HistoryService { if (!containerId) { continue; } + const containerKind = String(container?.job_kind || '').trim().toLowerCase(); + const isMultipartContainer = containerKind === 'multipart_movie_container'; const mkInfo = parseJsonSafe(container?.makemkv_info_json, {}); const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; const episodeCount = Number(selectedMetadata?.episodeCount || 0); const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0; - const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); - const containerChildRows = directDiskRowsByContainerId.get(containerId) || []; + const containerChildRowsRaw = directDiskRowsByContainerId.get(containerId) || []; + const containerChildRows = isMultipartContainer + ? containerChildRowsRaw.filter((row) => !isMultipartMergeRow(row)) + : containerChildRowsRaw; const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || []; + const expectedTotal = isMultipartContainer + ? containerChildRows.length + : (episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0)); const childIds = containerChildRows .map((row) => normalizeJobIdValue(row?.id)) .filter(Boolean); @@ -4127,9 +4162,90 @@ class HistoryService { encodeSuccessCount = Math.min(1, totalChildren); } } - const expectedFinal = expectedFromPlans > 0 - ? expectedFromPlans - : (expectedTotal > 0 ? expectedTotal : existingCount); + let mergeSummary = null; + if (isMultipartContainer) { + const mergeRows = containerChildRowsRaw.filter((row) => isMultipartMergeRow(row)); + let mergeJobId = null; + let mergeActive = false; + let mergeCompleted = false; + let mergeOutputExists = false; + for (const mergeRow of mergeRows) { + const mergeRowId = normalizeJobIdValue(mergeRow?.id); + if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) { + mergeJobId = mergeRowId; + } + const mergeStatus = String(mergeRow?.status || mergeRow?.last_state || '').trim().toUpperCase(); + if (mergeStatus === 'ENCODING') { + mergeActive = true; + } + const outputCandidates = new Set(); + const directOutputPath = String(mergeRow?.output_path || '').trim(); + if (directOutputPath) { + outputCandidates.add(directOutputPath); + } + if (mergeRowId) { + const linkedOutputs = Array.from(childOutputMap.get(mergeRowId) || []); + for (const outputPath of linkedOutputs) { + if (outputPath) { + outputCandidates.add(outputPath); + } + } + } + let rowOutputExists = false; + for (const outputPath of outputCandidates) { + if (!outputPath) { + continue; + } + if (!includeFsChecks || fs.existsSync(outputPath)) { + rowOutputExists = true; + break; + } + } + if (rowOutputExists) { + mergeOutputExists = true; + } + const mergeHbInfo = parseJsonSafe(mergeRow?.handbrake_info_json, null); + const mergeHbStatus = String(mergeHbInfo?.status || '').trim().toUpperCase(); + if (mergeStatus === 'FINISHED' || mergeHbStatus === 'SUCCESS' || rowOutputExists) { + mergeCompleted = true; + } + } + const mergeInputExpected = totalChildren; + const mergeInputReady = mergeInputExpected > 0 + ? Math.min(existingCount, mergeInputExpected) + : 0; + const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected; + const mergeMissingInputs = mergeInputExpected > mergeInputReady + ? (mergeInputExpected - mergeInputReady) + : 0; + let mergeState = 'missing'; + if (mergeActive) { + mergeState = 'active'; + } else if (mergeCompleted) { + mergeState = 'done'; + } else if (mergeReady) { + mergeState = mergeRows.length > 0 ? 'ready' : 'restorable'; + } else if (mergeRows.length > 0) { + mergeState = 'blocked'; + } + mergeSummary = { + hasJob: mergeRows.length > 0, + jobId: mergeJobId, + active: mergeActive, + completed: mergeCompleted, + ready: mergeReady, + outputExists: mergeOutputExists, + inputReady: mergeInputReady, + inputExpected: mergeInputExpected, + missingInputs: mergeMissingInputs, + state: mergeState + }; + } + const expectedFinal = isMultipartContainer + ? (totalChildren > 0 ? totalChildren : existingCount) + : (expectedFromPlans > 0 + ? expectedFromPlans + : (expectedTotal > 0 ? expectedTotal : existingCount)); containerOutputSummary.set(containerId, { existing: existingCount, expected: expectedFinal @@ -4139,7 +4255,8 @@ class HistoryService { containerChildSummary.set(containerId, { raw: { existing: rawExistsCount, expected: totalChildren }, backup: { existing: backupSuccessCount, expected: totalChildren }, - encode: { existing: encodeSuccessCount, expected: totalChildren } + encode: { existing: encodeSuccessCount, expected: totalChildren }, + ...(mergeSummary ? { merge: mergeSummary } : {}) }); } @@ -4586,6 +4703,8 @@ class HistoryService { childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings)) ); const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; + const isMultipartContainer = String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'; + const isDiskContainer = isSeriesContainer || isMultipartContainer; const detailChildJobs = isSeriesContainer ? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child)) : childJobs; @@ -4636,12 +4755,90 @@ class HistoryService { ); let seriesChildSummary = null; - if (isSeriesContainer && enrichedChildren.length > 0) { - const total = enrichedChildren.length; + if (isDiskContainer && enrichedChildren.length > 0) { + const summaryChildren = isMultipartContainer + ? enrichedChildren.filter((child) => !isMultipartMergeRow(child)) + : enrichedChildren; + const mergeChildren = isMultipartContainer + ? enrichedChildren.filter((child) => isMultipartMergeRow(child)) + : []; + const total = summaryChildren.length; + const outputReadyCount = summaryChildren.reduce((count, child) => ( + count + (child?.outputStatus?.exists ? 1 : 0) + ), 0); + const buildMergeSummary = () => { + if (!isMultipartContainer) { + return null; + } + let mergeJobId = null; + let mergeActive = false; + let mergeCompleted = false; + let mergeOutputExists = false; + for (const child of mergeChildren) { + const mergeRowId = normalizeJobIdValue(child?.id); + if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) { + mergeJobId = mergeRowId; + } + const mergeStatus = String(child?.status || child?.last_state || '').trim().toUpperCase(); + if (mergeStatus === 'ENCODING') { + mergeActive = true; + } + const hasOutput = Boolean(child?.outputStatus?.exists || String(child?.output_path || '').trim()); + if (hasOutput) { + mergeOutputExists = true; + } + if ( + mergeStatus === 'FINISHED' + || child?.encodeSuccess + || hasOutput + || String(child?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS' + ) { + mergeCompleted = true; + } + } + const mergeInputExpected = total; + const mergeInputReady = mergeInputExpected > 0 + ? Math.min(outputReadyCount, mergeInputExpected) + : 0; + const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected; + const mergeMissingInputs = mergeInputExpected > mergeInputReady + ? (mergeInputExpected - mergeInputReady) + : 0; + let mergeState = 'missing'; + if (mergeActive) { + mergeState = 'active'; + } else if (mergeCompleted) { + mergeState = 'done'; + } else if (mergeReady) { + mergeState = mergeChildren.length > 0 ? 'ready' : 'restorable'; + } else if (mergeChildren.length > 0) { + mergeState = 'blocked'; + } + return { + hasJob: mergeChildren.length > 0, + jobId: mergeJobId, + active: mergeActive, + completed: mergeCompleted, + ready: mergeReady, + outputExists: mergeOutputExists, + inputReady: mergeInputReady, + inputExpected: mergeInputExpected, + missingInputs: mergeMissingInputs, + state: mergeState + }; + }; + if (total <= 0) { + seriesChildSummary = { + raw: { existing: 0, expected: 0 }, + backup: { existing: 0, expected: 0 }, + encode: { existing: 0, expected: 0 }, + ...(isMultipartContainer ? { merge: buildMergeSummary() } : {}) + }; + } let rawCount = 0; let backupCount = 0; let encodeCount = 0; - for (const child of enrichedChildren) { + for (const child of summaryChildren) { if (child?.rawStatus?.exists) { rawCount += 1; } @@ -4659,11 +4856,14 @@ class HistoryService { encodeCount += 1; } } - seriesChildSummary = { - raw: { existing: rawCount, expected: total }, - backup: { existing: backupCount, expected: total }, - encode: { existing: encodeCount, expected: total } - }; + if (total > 0) { + seriesChildSummary = { + raw: { existing: rawCount, expected: total }, + backup: { existing: backupCount, expected: total }, + encode: { existing: encodeCount, expected: total }, + ...(isMultipartContainer ? { merge: buildMergeSummary() } : {}) + }; + } } const outputFolders = await this.getJobOutputFoldersForLineage(jobId); @@ -5856,7 +6056,13 @@ class HistoryService { : null; const isPrimarySeriesChild = isSeriesChildRow(primary) || (parentRow && isSeriesContainerRow(parentRow)); + const isPrimaryMultipartChild = isMultipartChildRow(primary) + || isMultipartMergeRow(primary) + || (parentRow && isMultipartContainerRow(parentRow)); const isPrimarySeriesContainer = isSeriesContainerRow(primary); + const isPrimaryMultipartContainer = isMultipartContainerRow(primary); + const isPrimaryScopedChild = isPrimarySeriesChild || isPrimaryMultipartChild; + const isPrimaryDiskContainer = isPrimarySeriesContainer || isPrimaryMultipartContainer; const enqueue = (value) => { const id = normalizeJobIdValue(value); if (!id || visited.has(id)) { @@ -5877,7 +6083,7 @@ class HistoryService { continue; } - if (!(isPrimarySeriesChild || isPrimarySeriesContainer)) { + if (!(isPrimaryScopedChild || isPrimaryDiskContainer)) { enqueue(row.parent_job_id); enqueue(parseSourceJobIdFromPlan(row.encode_plan_json)); } @@ -5889,7 +6095,7 @@ class HistoryService { enqueue(childId); } - if (includeLogLinks && !(isPrimarySeriesChild || isPrimarySeriesContainer)) { + if (includeLogLinks && !(isPrimaryScopedChild || isPrimaryDiskContainer)) { try { const processLog = await this.readProcessLogLines(currentId, { includeAll: true }); const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines); @@ -5902,7 +6108,7 @@ class HistoryService { } } - if (isPrimarySeriesChild && parentRow && isSeriesContainerRow(parentRow)) { + if (isPrimaryScopedChild && parentRow && isDiskContainerRow(parentRow)) { const parentId = normalizeJobIdValue(parentRow?.id); if (parentId) { const remainingChildren = rows.filter((row) => { @@ -7226,11 +7432,11 @@ class HistoryService { error.statusCode = 400; throw error; } - if (isSeriesContainerRow(existing)) { + if (isDiskContainerRow(existing)) { const containerChildren = await this.listChildJobs(normalizedJobId); if (Array.isArray(containerChildren) && containerChildren.length > 0) { const error = new Error( - 'Serien-Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.' + 'Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.' ); error.statusCode = 409; throw error; @@ -7385,7 +7591,7 @@ class HistoryService { // other children afterwards. for (const candidateId of [...deleteJobIds]) { const row = rowById.get(candidateId); - if (!row || !isSeriesContainerRow(row)) { + if (!row || !isDiskContainerRow(row)) { continue; } const childRows = await db.all( diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index e99aece..a410163 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const os = require('os'); const path = require('path'); const { EventEmitter } = require('events'); const { execFile } = require('child_process'); @@ -689,6 +690,7 @@ function normalizeJobKind(value) { || raw === 'dvd_series_child' || raw === 'multipart_movie_container' || raw === 'multipart_movie_child' + || raw === 'multipart_movie_merge' ) { return raw; } @@ -1638,30 +1640,435 @@ function resolveOutputPathParts(settings, values) { }; } +function appendSuffixToBaseName(baseName, suffix) { + const rawBase = String(baseName || '').trim() || 'untitled'; + const rawSuffix = String(suffix || '').trim(); + if (!rawSuffix) { + return rawBase; + } + return `${rawBase}${rawSuffix}`; +} + +function withPathBaseNameSuffix(targetPath, suffix) { + const normalizedPath = String(targetPath || '').trim(); + const normalizedSuffix = String(suffix || '').trim(); + if (!normalizedPath || !normalizedSuffix) { + return normalizedPath; + } + const parsed = path.parse(normalizedPath); + const nextBase = appendSuffixToBaseName(parsed.name || 'untitled', normalizedSuffix); + return path.join(parsed.dir || '', `${nextBase}${parsed.ext || ''}`); +} + +function buildMultipartDiscOutputSuffix(job = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const encodePlanJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase(); + if (jobKind === 'multipart_movie_merge' || encodePlanJobKind === 'multipart_movie_merge') { + return ''; + } + const hasMultipartFlag = Number(job?.is_multipart_movie || 0) === 1 + || Number(encodePlan?.isMultipartMovie || 0) === 1; + const isMultipartChild = jobKind === 'multipart_movie_child' + || encodePlanJobKind === 'multipart_movie_child' + || ( + hasMultipartFlag + && ( + normalizePositiveInteger(job?.parent_job_id) + || normalizePositiveInteger(encodePlan?.containerJobId) + || normalizePositiveInteger(job?.disc_number) + ) + ); + if (!isMultipartChild) { + return ''; + } + const discNumber = resolveDiscNumberFromJobLike(job); + if (!discNumber) { + return ''; + } + return `_Disc${discNumber}`; +} + +function parseDecimalSecondsToNanoseconds(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + const normalized = raw.replace(',', '.'); + if (/^-?\d+(?:\.\d+)?$/.test(normalized)) { + const sign = normalized.startsWith('-') ? -1 : 1; + const unsigned = sign < 0 ? normalized.slice(1) : normalized; + const parts = unsigned.split('.'); + const secondsPart = Number.parseInt(parts[0] || '0', 10); + if (!Number.isFinite(secondsPart)) { + return null; + } + const fractionRaw = parts[1] || ''; + const fractionPadded = `${fractionRaw}000000000`.slice(0, 9); + const fractionPart = Number.parseInt(fractionPadded, 10); + if (!Number.isFinite(fractionPart)) { + return null; + } + return sign * ((secondsPart * 1_000_000_000) + fractionPart); + } + if (/^\d+:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) { + const [hourPart, minutePart, secondPart] = normalized.split(':'); + const secondsNs = parseDecimalSecondsToNanoseconds(secondPart); + if (secondsNs == null) { + return null; + } + const hours = Number.parseInt(hourPart, 10); + const minutes = Number.parseInt(minutePart, 10); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { + return null; + } + return ((hours * 3600) + (minutes * 60)) * 1_000_000_000 + secondsNs; + } + return null; +} + +function formatNanosecondsAsClockTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '00:00:00'; + } + const totalSeconds = Math.max(0, Math.floor(numeric / 1_000_000_000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function formatNanosecondsAsMkvSimpleChapterTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '00:00:00.000000000'; + } + const totalNanoseconds = Math.max(0, Math.trunc(numeric)); + const hours = Math.floor(totalNanoseconds / 3_600_000_000_000); + const remainingAfterHours = totalNanoseconds - (hours * 3_600_000_000_000); + const minutes = Math.floor(remainingAfterHours / 60_000_000_000); + const remainingAfterMinutes = remainingAfterHours - (minutes * 60_000_000_000); + const seconds = Math.floor(remainingAfterMinutes / 1_000_000_000); + const nanos = remainingAfterMinutes - (seconds * 1_000_000_000); + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(nanos).padStart(9, '0')}`; +} + +function normalizeMultipartChapterName(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildMultipartMergedChapterPlan(sourceChapterGroups = []) { + const mergedEntries = []; + let offsetNanoseconds = 0; + for (const group of sourceChapterGroups) { + if (!group || typeof group !== 'object') { + continue; + } + const discLabel = normalizePositiveInteger(group.discNumber) + ? `Disc ${normalizePositiveInteger(group.discNumber)}` + : 'Disc ?'; + const chapters = Array.isArray(group.chapters) ? group.chapters : []; + const normalizedChapters = chapters + .map((chapter, chapterIndex) => { + const directStartNanoseconds = Number(chapter?.startNs); + const startNanoseconds = Number.isFinite(directStartNanoseconds) && directStartNanoseconds >= 0 + ? Math.trunc(directStartNanoseconds) + : parseDecimalSecondsToNanoseconds(chapter?.start_time); + if (startNanoseconds == null || startNanoseconds < 0) { + return null; + } + const existingTitle = normalizeMultipartChapterName( + chapter?.title + || chapter?.name + || chapter?.tags?.title + || '' + ); + const fallbackTitle = `Chapter ${String(chapterIndex + 1).padStart(2, '0')}`; + return { + startNanoseconds, + title: existingTitle || fallbackTitle + }; + }) + .filter(Boolean) + .sort((left, right) => left.startNanoseconds - right.startNanoseconds); + + if (normalizedChapters.length > 0) { + for (const chapter of normalizedChapters) { + mergedEntries.push({ + startNanoseconds: offsetNanoseconds + chapter.startNanoseconds, + discNumber: normalizePositiveInteger(group.discNumber) || null, + title: `${discLabel}: ${chapter.title}` + }); + } + } else { + mergedEntries.push({ + startNanoseconds: offsetNanoseconds, + discNumber: normalizePositiveInteger(group.discNumber) || null, + title: `${discLabel}` + }); + } + + const parsedDurationNanoseconds = parseDecimalSecondsToNanoseconds(group.duration ?? group.durationSeconds ?? null); + if (parsedDurationNanoseconds != null && parsedDurationNanoseconds > 0) { + offsetNanoseconds += parsedDurationNanoseconds; + } else if (normalizedChapters.length > 0) { + const highestStart = normalizedChapters[normalizedChapters.length - 1]?.startNanoseconds || 0; + offsetNanoseconds += highestStart + 1_000_000_000; + } + } + + if (mergedEntries.length === 0) { + return null; + } + + const entries = mergedEntries.map((entry, index) => ({ + index: index + 1, + discNumber: entry.discNumber || null, + startNanoseconds: Math.max(0, Math.trunc(Number(entry.startNanoseconds) || 0)), + start: formatNanosecondsAsMkvSimpleChapterTimestamp(entry.startNanoseconds), + startClock: formatNanosecondsAsClockTimestamp(entry.startNanoseconds), + title: normalizeMultipartChapterName(entry.title) || `Chapter ${String(index + 1).padStart(2, '0')}` + })); + + const lines = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const chapterNumber = String(index + 1).padStart(2, '0'); + lines.push(`CHAPTER${chapterNumber}=${entry.start}`); + lines.push(`CHAPTER${chapterNumber}NAME=${entry.title}`); + } + return { + entries, + content: lines.join('\n') + }; +} + +function buildMultipartMergedSimpleChapterFileContent(sourceChapterGroups = []) { + const plan = buildMultipartMergedChapterPlan(sourceChapterGroups); + return plan?.content || null; +} + +function normalizeMergeTrackLanguage(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized || normalized === 'und') { + return 'und'; + } + return normalized; +} + +function normalizeMergeTrackTitle(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function buildComparableMergeTrackSignature(track = null) { + const source = track && typeof track === 'object' ? track : {}; + const type = String(source.type || '').trim().toLowerCase(); + const language = normalizeMergeTrackLanguage(source.language); + const codec = String(source.codec || '').trim().toLowerCase() || '?'; + const channels = Number(source.channels); + const channelsLabel = Number.isFinite(channels) && channels > 0 ? String(Math.trunc(channels)) : '-'; + return `${type}|${codec}|${language}|${channelsLabel}`; +} + +function extractMergeTracksFromFfprobeJson(probeJson = null) { + const streams = Array.isArray(probeJson?.streams) ? probeJson.streams : []; + const tracks = []; + streams.forEach((stream, streamIndex) => { + const type = String(stream?.codec_type || '').trim().toLowerCase(); + if (type !== 'audio' && type !== 'subtitle') { + return; + } + const channelsRaw = Number(stream?.channels); + const channels = Number.isFinite(channelsRaw) && channelsRaw > 0 ? Math.trunc(channelsRaw) : null; + const indexRaw = Number(stream?.index); + tracks.push({ + type, + streamIndex: Number.isFinite(indexRaw) ? Math.trunc(indexRaw) : streamIndex, + codec: String(stream?.codec_name || stream?.codec_tag_string || '').trim() || '?', + language: normalizeMergeTrackLanguage(stream?.tags?.language), + title: normalizeMergeTrackTitle(stream?.tags?.title), + channels, + channelLayout: String(stream?.channel_layout || '').trim() || null, + default: Number(stream?.disposition?.default || 0) === 1, + forced: Number(stream?.disposition?.forced || 0) === 1 + }); + }); + const audioTracks = tracks.filter((track) => track.type === 'audio'); + const subtitleTracks = tracks.filter((track) => track.type === 'subtitle'); + return { + tracks, + audioTracks, + subtitleTracks, + layoutSignature: tracks.map((track) => buildComparableMergeTrackSignature(track)) + }; +} + +function buildMultipartMergeStreamSummary(sourceAnalyses = []) { + const analyses = Array.isArray(sourceAnalyses) + ? sourceAnalyses.filter((entry) => entry && typeof entry === 'object') + : []; + const comparable = analyses + .filter((entry) => entry.probeJson && !entry.error) + .map((entry) => ({ + ...entry, + ...extractMergeTracksFromFfprobeJson(entry.probeJson) + })); + if (comparable.length === 0) { + return { + available: false, + sourceCount: analyses.length, + comparableSourceCount: 0, + allSourcesAligned: false, + audioTracks: [], + subtitleTracks: [], + mismatches: [] + }; + } + const reference = comparable[0]; + const mismatchEntries = []; + for (const entry of comparable.slice(1)) { + const sameLength = reference.layoutSignature.length === entry.layoutSignature.length; + const sameSignature = sameLength + && reference.layoutSignature.every((signature, index) => signature === entry.layoutSignature[index]); + if (!sameSignature) { + mismatchEntries.push({ + sourceJobId: entry.sourceJobId || null, + discNumber: entry.discNumber || null, + expectedSignature: reference.layoutSignature, + actualSignature: entry.layoutSignature + }); + } + } + return { + available: true, + sourceCount: analyses.length, + comparableSourceCount: comparable.length, + allSourcesAligned: mismatchEntries.length === 0 && comparable.length === analyses.length, + audioTracks: reference.audioTracks.map((track, index) => ({ + ...track, + order: index + 1 + })), + subtitleTracks: reference.subtitleTracks.map((track, index) => ({ + ...track, + order: index + 1 + })), + mismatches: mismatchEntries + }; +} + +function quoteShellArgument(value) { + const raw = String(value ?? ''); + if (raw.length === 0) { + return "''"; + } + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(raw)) { + return raw; + } + return `'${raw.replace(/'/g, `'\"'\"'`)}'`; +} + +function buildShellCommandPreview(cmd, args = []) { + const command = quoteShellArgument(cmd); + const argv = Array.isArray(args) ? args : []; + if (argv.length === 0) { + return command; + } + return `${command} ${argv.map((arg) => quoteShellArgument(arg)).join(' ')}`; +} + +function normalizeMultipartMergeSourceItemsFromPlan(plan = null) { + const sourceItems = Array.isArray(plan?.sourceItems) ? plan.sourceItems : []; + const normalizedItems = sourceItems + .map((item) => { + if (!item || typeof item !== 'object') { + return null; + } + const jobId = normalizePositiveInteger(item?.jobId || item?.sourceJobId || null); + const discNumber = normalizePositiveInteger(item?.discNumber || null); + const outputPath = String(item?.outputPath || item?.path || '').trim(); + if (!jobId || !outputPath) { + return null; + } + return { + jobId, + discNumber, + outputPath + }; + }) + .filter(Boolean); + + if (normalizedItems.length === 0) { + return []; + } + + const orderedSourceJobIds = normalizeReviewTitleIdList( + Array.isArray(plan?.orderedSourceJobIds) + ? plan.orderedSourceJobIds + : normalizedItems.map((item) => item.jobId) + ); + const byJobId = new Map(normalizedItems.map((item) => [Number(item.jobId), item])); + const ordered = []; + const seen = new Set(); + for (const sourceJobId of orderedSourceJobIds) { + const item = byJobId.get(Number(sourceJobId)); + if (!item) { + continue; + } + const key = Number(item.jobId); + if (seen.has(key)) { + continue; + } + seen.add(key); + ordered.push(item); + } + for (const item of normalizedItems) { + const key = Number(item.jobId); + if (seen.has(key)) { + continue; + } + seen.add(key); + ordered.push(item); + } + return ordered; +} + function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const outputSuffix = jobKind === 'multipart_movie_merge' + ? '_merged' + : buildMultipartDiscOutputSuffix(job); const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); if (seriesParts?.rootDir) { + const seriesBaseName = appendSuffixToBaseName(seriesParts.baseName, outputSuffix); if (seriesParts.folderPath) { - return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesParts.baseName}.${seriesParts.ext}`); + return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesBaseName}.${seriesParts.ext}`); } - return path.join(seriesParts.rootDir, `${seriesParts.baseName}.${seriesParts.ext}`); + return path.join(seriesParts.rootDir, `${seriesBaseName}.${seriesParts.ext}`); } const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { folderPath, baseName } = resolveOutputPathParts(settings, values); + const outputBaseName = appendSuffixToBaseName(baseName, outputSuffix); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; if (folderPath) { - return path.join(movieDir, folderPath, `${baseName}.${ext}`); + return path.join(movieDir, folderPath, `${outputBaseName}.${ext}`); } - return path.join(movieDir, `${baseName}.${ext}`); + return path.join(movieDir, `${outputBaseName}.${ext}`); } function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const outputSuffix = jobKind === 'multipart_movie_merge' + ? '_merged' + : buildMultipartDiscOutputSuffix(job); const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); const movieDir = seriesParts?.rootDir || settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { baseName: defaultBaseName } = resolveOutputPathParts(settings, values); - const baseName = seriesParts?.baseName || defaultBaseName; + const baseName = appendSuffixToBaseName(seriesParts?.baseName || defaultBaseName, outputSuffix); const ext = String(seriesParts?.ext || settings.output_extension || 'mkv').trim() || 'mkv'; const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 @@ -5371,7 +5778,20 @@ function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) || analyzeContext?.mediaProfile || null ); + const workflowKind = normalizeDvdMetadataWorkflowKind( + opts.workflowKind + ?? selectedMetadata?.workflowKind + ?? analyzeContext?.workflowKind + ?? analyzeContext?.selectedMetadata?.workflowKind + ?? null + ); + const jobKind = String(jobLike?.job_kind || '').trim().toLowerCase(); + const isMultipartMovie = opts.isMultipartMovie === true + || Number(jobLike?.is_multipart_movie || 0) === 1 + || jobKind === 'multipart_movie_child' + || jobKind === 'multipart_movie_container'; const isSeriesDisc = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const isDiscFilm = isSeriesDiscMediaProfile(mediaProfile) && workflowKind === 'film'; const seasonNumber = normalizePositiveInteger( opts.seriesSeasonNumber ?? selectedMetadata?.seasonNumber @@ -5407,6 +5827,17 @@ function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) return sanitizeFileName(`${seriesTitle} (${yearValue}) - S${seasonNumber}D${discNumber}`); } + if (isDiscFilm && isMultipartMovie && discNumber) { + const movieTitle = normalizeCdTrackText( + selectedMetadata?.title + || jobLike?.title + || jobLike?.detected_title + || jobLike?.detectedTitle + || fallbackTitle + ) || fallbackTitle; + return sanitizeFileName(`${movieTitle} (${yearValue}) - D${discNumber}`); + } + return sanitizeFileName( renderTemplate('${title} (${year})', { title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle, @@ -7598,7 +8029,9 @@ function extractManualSelectionPayloadFromPlan(encodePlan) { if (!selection) { return null; } + const normalizedTitleId = normalizeReviewTitleId(selection.titleId ?? encodePlan?.encodeInputTitleId); return { + titleId: normalizedTitleId || null, audioTrackIds: normalizeTrackIdList(selection.audioTrackIds), subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds), subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }), @@ -7750,7 +8183,56 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) { return normalizeReviewTitleId(fallback?.id); } -function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) { +function normalizeTrackSignatureText(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function resolveSubtitleForcedOnlyForSignature(track) { + const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); + return Boolean( + track?.isForcedOnly + ?? track?.subtitlePreviewForcedOnly + ?? track?.forcedOnly + ?? track?.forcedTrackOnly + ?? subtitleType === 'forced' + ); +} + +function resolveSubtitleFullHasForcedForSignature(track) { + return Boolean( + track?.fullHasForced + ?? track?.subtitleFullHasForced + ?? track?.hasForcedVariant + ?? track?.fullTrackHasForced + ?? false + ); +} + +function buildAudioTrackSignature(track) { + return [ + normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), + normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), + normalizeTrackSignatureText(track?.channels || track?.channelCount || ''), + normalizeTrackSignatureText(track?.channelLayout || ''), + normalizeTrackSignatureText(track?.title || track?.description || '') + ].join('|'); +} + +function buildSubtitleTrackSignature(track) { + return [ + normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), + normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), + resolveSubtitleForcedOnlyForSignature(track) ? 'forced' : 'full', + resolveSubtitleFullHasForcedForSignature(track) ? 'full_has_forced' : 'plain' + ].join('|'); +} + +function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, options = {}) { + const excludeBurned = Boolean(options?.excludeBurned); + const kind = String(options?.kind || 'audio').trim().toLowerCase() === 'subtitle' + ? 'subtitle' + : 'audio'; + const sourceTracks = Array.isArray(options?.sourceTracks) ? options.sourceTracks : []; const tracks = Array.isArray(targetTracks) ? targetTracks : []; const allowedTracks = excludeBurned ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) @@ -7760,25 +8242,90 @@ function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, return []; } + const targetMeta = allowedTracks + .map((track, index) => { + const targetId = normalizeTrackIdList([track?.id])[0] || null; + if (targetId === null) { + return null; + } + return { + track, + index, + targetId, + sourceId: normalizeTrackIdList([track?.sourceTrackId])[0] || null, + signature: kind === 'subtitle' + ? buildSubtitleTrackSignature(track) + : buildAudioTrackSignature(track) + }; + }) + .filter(Boolean); + const mapped = []; const seen = new Set(); + const usedTargetIds = new Set(); for (const sourceTrackId of requested) { - const match = allowedTracks.find((track) => { - const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null; - const reviewId = normalizeTrackIdList([track?.id])[0] || null; - return sourceId === sourceTrackId || reviewId === sourceTrackId; - }) || null; - const targetId = normalizeTrackIdList([match?.id])[0] || null; - if (targetId === null) { + const match = targetMeta.find((entry) => + !usedTargetIds.has(String(entry.targetId)) + && (entry.sourceId === sourceTrackId || entry.targetId === sourceTrackId) + ) || null; + if (!match) { continue; } + const targetId = match.targetId; const key = String(targetId); if (seen.has(key)) { continue; } seen.add(key); + usedTargetIds.add(key); mapped.push(targetId); } + + if (mapped.length >= requested.length) { + return mapped; + } + + const requestedSet = new Set(requested.map((id) => String(id))); + const sourceMeta = sourceTracks + .map((track, index) => { + const sourceId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + if (sourceId === null) { + return null; + } + if (!requestedSet.has(String(sourceId))) { + return null; + } + return { + sourceId, + index, + signature: kind === 'subtitle' + ? buildSubtitleTrackSignature(track) + : buildAudioTrackSignature(track) + }; + }) + .filter(Boolean) + .sort((left, right) => left.index - right.index); + + for (const source of sourceMeta) { + if (!source.signature) { + continue; + } + const match = targetMeta.find((entry) => + !usedTargetIds.has(String(entry.targetId)) + && entry.signature === source.signature + ) || null; + if (!match) { + continue; + } + const key = String(match.targetId); + if (seen.has(key)) { + continue; + } + seen.add(key); + usedTargetIds.add(key); + mapped.push(match.targetId); + } + return mapped; } @@ -7815,25 +8362,33 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n const nextSelectedTitle = findSelectedTitleInPlan(nextPlan); let trackSelectionApplied = false; if (previousSelectedTitle && nextSelectedTitle) { + const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)); + const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)); const previousAudioSourceIds = normalizeTrackIdList( - (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) - .filter((track) => Boolean(track?.selectedForEncode)) - .map((track) => track?.sourceTrackId ?? track?.id) + previousSelectedAudioTracks.map((track) => track?.sourceTrackId ?? track?.id) ); const previousSubtitleSourceIds = normalizeTrackIdList( - (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) - .filter((track) => Boolean(track?.selectedForEncode)) - .map((track) => track?.sourceTrackId ?? track?.id) + previousSelectedSubtitleTracks.map((track) => track?.sourceTrackId ?? track?.id) ); const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.audioTracks, - previousAudioSourceIds + previousAudioSourceIds, + { + kind: 'audio', + sourceTracks: previousSelectedAudioTracks + } ); const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.subtitleTracks, previousSubtitleSourceIds, - { excludeBurned: true } + { + kind: 'subtitle', + sourceTracks: previousSelectedSubtitleTracks, + excludeBurned: true + } ); const fallbackAudioTrackIds = normalizeTrackIdList( (Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : []) @@ -7906,6 +8461,42 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n }; } +function applyMultipartSettingsLockToReviewPlan(reviewPlan, lockContext = null) { + const plan = reviewPlan && typeof reviewPlan === 'object' + ? reviewPlan + : null; + const lock = lockContext && typeof lockContext === 'object' + ? lockContext + : null; + const lockEnabled = Boolean(lock?.enabled); + const sourceJobId = normalizePositiveInteger(lock?.sourceJobId || null); + if (!plan || !lockEnabled) { + return { + plan: reviewPlan, + applied: false, + sourceJobId: null, + sourceDiscNumber: null + }; + } + + const sourceDiscNumber = normalizePositiveInteger(lock?.sourceDiscNumber || null); + return { + plan: { + ...plan, + multipartSettingsLock: { + enabled: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null, + strategy: 'reuse_existing_disc_settings', + lockedAt: nowIso() + } + }, + applied: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null + }; +} + class PipelineService extends EventEmitter { constructor() { super(); @@ -8743,7 +9334,9 @@ class PipelineService extends EventEmitter { year: row.year || null, fallbackYear, detected_title: row.detected_title || null, - media_type: row.media_type || null + media_type: row.media_type || null, + is_multipart_movie: Number(row?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: row?.job_kind || null }, jobId, { mediaProfile: row.media_type || null, analyzeContext, @@ -8937,6 +9530,14 @@ class PipelineService extends EventEmitter { }); } + try { + await this.normalizeMultipartOutputsOnStartup(db); + } catch (multipartOutputNormalizeError) { + logger.warn('init:multipart-output-normalize-failed', { + error: errorToMeta(multipartOutputNormalizeError) + }); + } + try { await this.syncAllSeriesContainerStatusesOnStartup(db); } catch (seriesContainerRecoveryError) { @@ -9557,7 +10158,7 @@ class PipelineService extends EventEmitter { FROM jobs WHERE disc_device IS NOT NULL AND TRIM(disc_device) <> '' - AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED') ` ); @@ -9618,7 +10219,10 @@ class PipelineService extends EventEmitter { 'CANCELLED' ]); const deleted = []; - for (const row of (Array.isArray(rows) ? rows : [])) { + const replaceableRows = Array.isArray(rows) ? rows : []; + const containerRows = []; + + for (const row of replaceableRows) { const jobId = Number(row?.id); if (!Number.isFinite(jobId) || jobId <= 0) { continue; @@ -9628,6 +10232,10 @@ class PipelineService extends EventEmitter { if (!replaceableStates.has(rowState)) { continue; } + if (this.isContainerHistoryJob(row)) { + containerRows.push(row); + continue; + } if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); error.statusCode = 409; @@ -9651,6 +10259,44 @@ class PipelineService extends EventEmitter { deleted.push(normalizedJobId); } + // Delete now-empty container anchors only after child rows were processed. + for (const row of containerRows) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const normalizedJobId = Math.trunc(jobId); + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!replaceableStates.has(rowState)) { + continue; + } + if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { + const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); + error.statusCode = 409; + throw error; + } + const hasChildren = await db.get( + ` + SELECT id + FROM jobs + WHERE parent_job_id = ? + LIMIT 1 + `, + [normalizedJobId] + ); + if (hasChildren) { + logger.info('analyze:drive:skip-container-cleanup', { + devicePath: normalizedPath, + containerJobId: normalizedJobId, + reason: 'children_present' + }); + continue; + } + await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); + await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); + deleted.push(normalizedJobId); + } + if (deleted.length > 0) { logger.info('analyze:drive:replace-existing-jobs', { devicePath: normalizedPath, @@ -10199,6 +10845,21 @@ class PipelineService extends EventEmitter { return jobKind === 'multipart_movie_container'; } + isMultipartMovieMergeHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'multipart_movie_merge'; + } + + isMultipartMovieDiscChildHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'multipart_movie_child' + || ( + Number(jobLike?.is_multipart_movie || 0) === 1 + && this.normalizeQueueJobId(jobLike?.parent_job_id) + && jobKind !== 'multipart_movie_merge' + ); + } + isContainerHistoryJob(jobLike = null) { return this.isSeriesContainerHistoryJob(jobLike) || this.isMultipartMovieContainerHistoryJob(jobLike); } @@ -10320,7 +10981,9 @@ class PipelineService extends EventEmitter { `, [normalizedParentJobId] ); - if (!parentJob || !this.isSeriesContainerHistoryJob(parentJob)) { + const isSeriesContainer = this.isSeriesContainerHistoryJob(parentJob); + const isMultipartContainer = this.isMultipartMovieContainerHistoryJob(parentJob); + if (!parentJob || (!isSeriesContainer && !isMultipartContainer)) { return null; } @@ -10328,7 +10991,16 @@ class PipelineService extends EventEmitter { ? options.childJobs : await historyService.listChildJobs(normalizedParentJobId); const childRows = (Array.isArray(sourceChildRows) ? sourceChildRows : []) - .filter((row) => Number(row?.id) > 0 && !this.isSeriesContainerHistoryJob(row)); + .filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row)) + .filter((row) => { + if (isSeriesContainer) { + return !isSeriesBatchEpisodeSubJobRow(row); + } + if (isMultipartContainer) { + return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row); + } + return true; + }); if (childRows.length === 0) { return { parentJobId: normalizedParentJobId, @@ -10386,7 +11058,7 @@ class PipelineService extends EventEmitter { const rows = await database.all(` SELECT id FROM jobs - WHERE COALESCE(job_kind, '') = 'dvd_series_container' + WHERE COALESCE(job_kind, '') IN ('dvd_series_container', 'multipart_movie_container') ORDER BY id ASC `); const containerRows = Array.isArray(rows) ? rows : []; @@ -10423,6 +11095,1130 @@ class PipelineService extends EventEmitter { }; } + async resolveMultipartReviewLockOptions(jobOrId, options = {}) { + const baseOptions = options && typeof options === 'object' + ? { ...options } + : {}; + const existingPreviousEncodePlan = baseOptions?.previousEncodePlan && typeof baseOptions.previousEncodePlan === 'object' + ? baseOptions.previousEncodePlan + : null; + const existingLock = baseOptions?.multipartSettingsLock && typeof baseOptions.multipartSettingsLock === 'object' + ? baseOptions.multipartSettingsLock + : null; + const preloadedJob = jobOrId && typeof jobOrId === 'object' + ? jobOrId + : null; + const normalizedJobId = this.normalizeQueueJobId(preloadedJob?.id || jobOrId); + if (!normalizedJobId) { + return baseOptions; + } + const job = preloadedJob || await historyService.getJobById(normalizedJobId).catch(() => null); + if (!job || !this.isMultipartMovieDiscChildHistoryJob(job)) { + return baseOptions; + } + const parentJobId = this.normalizeQueueJobId(job?.parent_job_id); + if (!parentJobId) { + return baseOptions; + } + + let sourcePlan = existingPreviousEncodePlan; + let sourceJobId = this.normalizeQueueJobId(existingLock?.sourceJobId); + let sourceDiscNumber = normalizePositiveInteger(existingLock?.sourceDiscNumber || null); + const shouldResolveSourceFromSiblings = ( + !sourcePlan + || !Array.isArray(sourcePlan?.titles) + || sourcePlan.titles.length === 0 + || !sourceJobId + || sourceJobId === normalizedJobId + ); + if (shouldResolveSourceFromSiblings) { + const childRows = await historyService.listChildJobs(parentJobId).catch(() => []); + const candidates = (Array.isArray(childRows) ? childRows : []) + .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) + .filter((row) => this.normalizeQueueJobId(row?.id) !== normalizedJobId) + .map((row) => { + const candidatePlan = this.safeParseJson(row?.encode_plan_json); + const hasTitles = Array.isArray(candidatePlan?.titles) && candidatePlan.titles.length > 0; + if (!hasTitles) { + return null; + } + const status = String(row?.status || row?.last_state || '').trim().toUpperCase(); + const reviewConfirmed = Number(row?.encode_review_confirmed || 0) === 1 || Boolean(candidatePlan?.reviewConfirmed); + return { + row, + plan: candidatePlan, + reviewConfirmed, + finished: status === 'FINISHED', + discNumber: resolveDiscNumberFromJobLike(row) || Number.MAX_SAFE_INTEGER, + id: this.normalizeQueueJobId(row?.id) || Number.MAX_SAFE_INTEGER + }; + }) + .filter(Boolean) + .sort((left, right) => { + if (left.reviewConfirmed !== right.reviewConfirmed) { + return left.reviewConfirmed ? -1 : 1; + } + if (left.finished !== right.finished) { + return left.finished ? -1 : 1; + } + if (left.discNumber !== right.discNumber) { + return left.discNumber - right.discNumber; + } + return left.id - right.id; + }); + const source = candidates[0] || null; + if (!source && (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0)) { + return baseOptions; + } + if (source) { + sourcePlan = source.plan; + sourceJobId = source.id; + sourceDiscNumber = Number.isFinite(source.discNumber) && source.discNumber > 0 + ? source.discNumber + : null; + } + } + + if (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0) { + return baseOptions; + } + + return { + ...baseOptions, + previousEncodePlan: sourcePlan, + multipartSettingsLock: { + enabled: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null + } + }; + } + + async normalizeMultipartOutputsOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT + child.id, + child.parent_job_id, + child.job_kind, + child.is_multipart_movie, + child.output_path, + child.status, + child.last_state, + child.disc_number, + child.makemkv_info_json, + parent.job_kind AS parent_job_kind, + parent.title AS parent_title, + parent.detected_title AS parent_detected_title + FROM jobs child + LEFT JOIN jobs parent ON parent.id = child.parent_job_id + WHERE child.parent_job_id IS NOT NULL + AND child.output_path IS NOT NULL + AND TRIM(child.output_path) <> '' + AND ( + COALESCE(child.job_kind, '') = 'multipart_movie_child' + OR COALESCE(child.is_multipart_movie, 0) = 1 + ) + ORDER BY child.parent_job_id ASC, child.id ASC + `); + const childRows = Array.isArray(rows) ? rows : []; + if (childRows.length === 0) { + return { + scanned: 0, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + mergePlansRefreshed: 0, + mergePlanRefreshFailed: 0 + }; + } + + let updated = 0; + let unchanged = 0; + let skipped = 0; + let failed = 0; + let mergePlansRefreshed = 0; + let mergePlanRefreshFailed = 0; + const refreshContainerIds = new Set(); + const containerRowsById = new Map(); + + for (const row of childRows) { + const childJobId = this.normalizeQueueJobId(row?.id); + const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); + if (!childJobId || !parentJobId) { + skipped += 1; + continue; + } + const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); + if (parentKind !== 'multipart_movie_container') { + skipped += 1; + continue; + } + if (!this.isMultipartMovieDiscChildHistoryJob(row)) { + skipped += 1; + continue; + } + + const outputPath = String(row?.output_path || '').trim(); + if (!outputPath || !fs.existsSync(outputPath)) { + skipped += 1; + continue; + } + refreshContainerIds.add(parentJobId); + + try { + const result = await this.ensureMultipartChildOutputHasDiscSuffix(childJobId, { childJob: row }); + if (result?.updated) { + updated += 1; + } else { + unchanged += 1; + } + } catch (error) { + failed += 1; + logger.warn('startup:multipart-output-normalize:rename-failed', { + childJobId, + parentJobId, + outputPath, + error: errorToMeta(error) + }); + } + + if (!containerRowsById.has(parentJobId)) { + containerRowsById.set(parentJobId, { + id: parentJobId, + job_kind: 'multipart_movie_container', + title: row?.parent_title || null, + detected_title: row?.parent_detected_title || null + }); + } + } + + for (const containerJobId of refreshContainerIds) { + try { + await this.upsertMultipartMergeJobForContainer(containerJobId, { + containerJob: containerRowsById.get(containerJobId) || null, + createIfMissing: false + }); + mergePlansRefreshed += 1; + } catch (error) { + mergePlanRefreshFailed += 1; + logger.warn('startup:multipart-output-normalize:merge-plan-refresh-failed', { + containerJobId, + error: errorToMeta(error) + }); + } + } + + if (updated > 0 || failed > 0 || skipped > 0 || mergePlansRefreshed > 0 || mergePlanRefreshFailed > 0) { + logger.info('startup:multipart-output-normalize:done', { + scanned: childRows.length, + updated, + unchanged, + skipped, + failed, + mergePlansRefreshed, + mergePlanRefreshFailed + }); + } + + return { + scanned: childRows.length, + updated, + unchanged, + skipped, + failed, + mergePlansRefreshed, + mergePlanRefreshFailed + }; + } + + async ensureMultipartChildOutputHasDiscSuffix(childJobId, options = {}) { + const normalizedChildJobId = this.normalizeQueueJobId(childJobId); + if (!normalizedChildJobId) { + return null; + } + const childJob = options?.childJob && Number(options.childJob?.id) === Number(normalizedChildJobId) + ? options.childJob + : await historyService.getJobById(normalizedChildJobId); + if (!childJob || !this.isMultipartMovieDiscChildHistoryJob(childJob)) { + return null; + } + + const sourceOutputPath = String(childJob?.output_path || '').trim(); + if (!sourceOutputPath) { + return { + jobId: normalizedChildJobId, + outputPath: null, + updated: false + }; + } + + const discSuffix = buildMultipartDiscOutputSuffix(childJob); + if (!discSuffix) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const sourceParsed = path.parse(sourceOutputPath); + if (String(sourceParsed.name || '').toLowerCase().endsWith(String(discSuffix).toLowerCase())) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + let targetOutputPath = withPathBaseNameSuffix(sourceOutputPath, discSuffix); + if (!targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath)) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const sourceExists = fs.existsSync(sourceOutputPath); + const targetExists = fs.existsSync(targetOutputPath); + if (sourceExists && targetExists) { + targetOutputPath = ensureUniqueOutputPath(targetOutputPath); + } + + if (sourceExists) { + ensureDir(path.dirname(targetOutputPath)); + moveFileWithFallback(sourceOutputPath, targetOutputPath); + } else if (!targetExists) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + await historyService.updateJob(normalizedChildJobId, { output_path: targetOutputPath }); + historyService.addJobOutputFolder(normalizedChildJobId, targetOutputPath).catch((error) => { + logger.warn('multipart:child-output:add-output-folder-failed', { + childJobId: normalizedChildJobId, + outputPath: targetOutputPath, + error: errorToMeta(error) + }); + }); + await historyService.appendLog( + normalizedChildJobId, + 'SYSTEM', + `Multipart-Output umbenannt: ${sourceOutputPath} -> ${targetOutputPath}` + ); + return { + jobId: normalizedChildJobId, + outputPath: targetOutputPath, + updated: true + }; + } + + async upsertMultipartMergeJobForContainer(containerJobId, options = {}) { + const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); + if (!normalizedContainerJobId) { + return null; + } + const containerJob = options?.containerJob && Number(options.containerJob?.id) === Number(normalizedContainerJobId) + ? options.containerJob + : await historyService.getJobById(normalizedContainerJobId); + if (!containerJob || !this.isMultipartMovieContainerHistoryJob(containerJob)) { + return null; + } + + const allChildren = await historyService.listChildJobs(normalizedContainerJobId); + const mergeJobs = (Array.isArray(allChildren) ? allChildren : []) + .filter((row) => this.isMultipartMovieMergeHistoryJob(row)) + .sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); + const discChildRows = (Array.isArray(allChildren) ? allChildren : []) + .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) + .sort((left, right) => { + const leftDisc = resolveDiscNumberFromJobLike(left) || Number.MAX_SAFE_INTEGER; + const rightDisc = resolveDiscNumberFromJobLike(right) || Number.MAX_SAFE_INTEGER; + if (leftDisc !== rightDisc) { + return leftDisc - rightDisc; + } + return Number(left?.id || 0) - Number(right?.id || 0); + }); + + if (discChildRows.length < 2) { + return { + handled: false, + reason: 'not_enough_disc_children', + discChildCount: discChildRows.length + }; + } + + const refreshedDiscChildren = []; + for (const child of discChildRows) { + const normalizedChildId = this.normalizeQueueJobId(child?.id); + if (!normalizedChildId) { + continue; + } + await this.ensureMultipartChildOutputHasDiscSuffix(normalizedChildId, { childJob: child }).catch((error) => { + logger.warn('multipart:child-output:disc-suffix-failed', { + childJobId: normalizedChildId, + error: errorToMeta(error) + }); + }); + const refreshedChild = await historyService.getJobById(normalizedChildId).catch(() => child); + if (refreshedChild) { + refreshedDiscChildren.push(refreshedChild); + } + } + + const missingRequirements = []; + for (const child of refreshedDiscChildren) { + const childId = this.normalizeQueueJobId(child?.id); + const childState = String(child?.status || child?.last_state || '').trim().toUpperCase(); + const outputPath = String(child?.output_path || '').trim(); + if (childState !== 'FINISHED') { + missingRequirements.push({ + jobId: childId, + reason: 'state_not_finished', + state: childState + }); + continue; + } + if (!outputPath || !fs.existsSync(outputPath)) { + missingRequirements.push({ + jobId: childId, + reason: 'output_missing', + outputPath: outputPath || null + }); + } + } + if (missingRequirements.length > 0) { + return { + handled: false, + reason: 'disc_children_not_ready', + missingRequirements + }; + } + + const settings = await settingsService.getEffectiveSettingsMap( + this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }) + ); + const templateOutputPath = buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId); + const mergeOutputPath = withPathBaseNameSuffix(templateOutputPath, '_merged'); + const existingMergeJob = mergeJobs[0] || null; + const createIfMissing = options?.createIfMissing !== false; + const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {}; + + const defaultOrderIds = refreshedDiscChildren.map((child) => this.normalizeQueueJobId(child?.id)).filter(Boolean); + const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); + const orderedSourceJobIds = []; + const seenOrderIds = new Set(); + for (const sourceJobId of existingOrderIds) { + if (!defaultOrderIds.includes(Number(sourceJobId))) { + continue; + } + if (seenOrderIds.has(Number(sourceJobId))) { + continue; + } + seenOrderIds.add(Number(sourceJobId)); + orderedSourceJobIds.push(Number(sourceJobId)); + } + for (const sourceJobId of defaultOrderIds) { + if (seenOrderIds.has(Number(sourceJobId))) { + continue; + } + seenOrderIds.add(Number(sourceJobId)); + orderedSourceJobIds.push(Number(sourceJobId)); + } + + const discByJobId = new Map( + refreshedDiscChildren.map((child) => [ + Number(child.id), + { + discNumber: resolveDiscNumberFromJobLike(child), + outputPath: String(child?.output_path || '').trim() + } + ]) + ); + const sourceItems = orderedSourceJobIds + .map((sourceJobId) => { + const source = discByJobId.get(Number(sourceJobId)); + if (!source || !source.outputPath) { + return null; + } + return { + jobId: Number(sourceJobId), + discNumber: source.discNumber || null, + outputPath: source.outputPath + }; + }) + .filter(Boolean); + if (sourceItems.length < 2) { + return { + handled: false, + reason: 'insufficient_merge_inputs', + sourceCount: sourceItems.length + }; + } + + const mergePlan = { + mode: 'multipart_merge', + preRip: false, + reviewConfirmed: true, + reviewConfirmedAt: nowIso(), + mediaProfile: this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }), + jobKind: 'multipart_movie_merge', + containerJobId: normalizedContainerJobId, + orderedSourceJobIds, + sourceItems, + mergeOutputPath, + mergeStrategy: 'mkvmerge_append', + updatedAt: nowIso() + }; + + if (existingMergeJob) { + const existingStatus = String(existingMergeJob?.status || existingMergeJob?.last_state || '').trim().toUpperCase(); + const isRunning = existingStatus === 'ENCODING'; + await historyService.updateJob(existingMergeJob.id, { + parent_job_id: normalizedContainerJobId, + title: containerJob.title || containerJob.detected_title || existingMergeJob.detected_title || null, + year: containerJob.year || null, + imdb_id: containerJob.imdb_id || null, + poster_url: containerJob.poster_url || null, + selected_from_omdb: Number(containerJob.selected_from_omdb || 0) || 0, + omdb_json: containerJob.omdb_json || null, + media_type: containerJob.media_type || null, + job_kind: 'multipart_movie_merge', + is_multipart_movie: 1, + metadata_fingerprint: containerJob.metadata_fingerprint || null, + makemkv_info_json: containerJob.makemkv_info_json || null, + encode_plan_json: JSON.stringify(mergePlan), + encode_review_confirmed: 1, + ...(isRunning + ? {} + : { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + output_path: null, + encode_input_path: null, + handbrake_info_json: null, + error_message: null, + end_time: null + }) + }); + return { + handled: true, + mergeJobId: Number(existingMergeJob.id), + created: false, + sourceCount: sourceItems.length + }; + } + + if (!createIfMissing) { + return { + handled: false, + reason: 'merge_job_missing', + sourceCount: sourceItems.length + }; + } + + const createdMergeJob = await historyService.createJob({ + discDevice: null, + status: 'READY_TO_START', + detectedTitle: containerJob.title || containerJob.detected_title || null, + mediaType: containerJob.media_type || null, + jobKind: 'multipart_movie_merge' + }); + const createdMergeJobId = this.normalizeQueueJobId(createdMergeJob?.id); + if (!createdMergeJobId) { + const error = new Error('Merge-Job konnte nicht erstellt werden.'); + error.statusCode = 500; + throw error; + } + + await historyService.updateJob(createdMergeJobId, { + parent_job_id: normalizedContainerJobId, + title: containerJob.title || containerJob.detected_title || null, + year: containerJob.year || null, + imdb_id: containerJob.imdb_id || null, + poster_url: containerJob.poster_url || null, + selected_from_omdb: Number(containerJob.selected_from_omdb || 0) || 0, + omdb_json: containerJob.omdb_json || null, + media_type: containerJob.media_type || null, + job_kind: 'multipart_movie_merge', + is_multipart_movie: 1, + disc_number: null, + metadata_fingerprint: containerJob.metadata_fingerprint || null, + makemkv_info_json: containerJob.makemkv_info_json || null, + encode_plan_json: JSON.stringify(mergePlan), + encode_review_confirmed: 1 + }); + await historyService.appendLog( + createdMergeJobId, + 'SYSTEM', + `Merge-Job vorbereitet (${sourceItems.length} Datei(en)). Reihenfolge: ` + + sourceItems.map((item) => `Disc ${item.discNumber || '?'} (Job #${item.jobId})`).join(' | ') + ); + + return { + handled: true, + mergeJobId: createdMergeJobId, + created: true, + sourceCount: sourceItems.length + }; + } + + async updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds = []) { + 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-Reihenfolge kann während des laufenden Merges nicht geändert werden.'); + error.statusCode = 409; + throw error; + } + + const plan = this.safeParseJson(job.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(plan); + if (sourceItems.length < 2) { + const error = new Error('Merge-Job enthält keine vollständige Source-Liste.'); + error.statusCode = 409; + throw error; + } + const normalizedOrder = normalizeReviewTitleIdList( + Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : [] + ); + if (normalizedOrder.length === 0) { + const error = new Error('orderedSourceJobIds darf nicht leer sein.'); + error.statusCode = 400; + throw error; + } + const byJobId = new Map(sourceItems.map((item) => [Number(item.jobId), item])); + const nextItems = []; + const seen = new Set(); + for (const sourceJobId of normalizedOrder) { + const item = byJobId.get(Number(sourceJobId)); + if (!item || seen.has(Number(sourceJobId))) { + continue; + } + seen.add(Number(sourceJobId)); + nextItems.push(item); + } + for (const item of sourceItems) { + const sourceJobId = Number(item.jobId); + if (seen.has(sourceJobId)) { + continue; + } + seen.add(sourceJobId); + nextItems.push(item); + } + if (nextItems.length < 2) { + const error = new Error('Merge-Reihenfolge ist unvollständig.'); + error.statusCode = 400; + throw error; + } + + const nextPlan = { + ...plan, + orderedSourceJobIds: nextItems.map((item) => Number(item.jobId)), + sourceItems: nextItems, + updatedAt: nowIso() + }; + await historyService.updateJob(normalizedJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + end_time: null, + error_message: null, + output_path: null, + encode_plan_json: JSON.stringify(nextPlan) + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Merge-Reihenfolge aktualisiert: ${nextItems.map((item) => `Job #${item.jobId}`).join(' -> ')}` + ); + return historyService.getJobById(normalizedJobId); + } + + async restoreMultipartMergeJobForContainer(containerJobId, options = {}) { + const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); + if (!normalizedContainerJobId) { + const error = new Error('Ungültige Container-Job-ID.'); + error.statusCode = 400; + throw error; + } + + const containerJob = options?.preloadedContainerJob && Number(options.preloadedContainerJob?.id) === Number(normalizedContainerJobId) + ? options.preloadedContainerJob + : await historyService.getJobById(normalizedContainerJobId); + if (!containerJob) { + const error = new Error(`Container-Job ${normalizedContainerJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + if (!this.isMultipartMovieContainerHistoryJob(containerJob)) { + const error = new Error(`Job #${normalizedContainerJobId} ist kein Multipart-Movie-Container.`); + error.statusCode = 409; + throw error; + } + + const allChildren = await historyService.listChildJobs(normalizedContainerJobId); + const discChildren = (Array.isArray(allChildren) ? allChildren : []) + .filter((child) => this.isMultipartMovieDiscChildHistoryJob(child)); + if (discChildren.length < 2) { + const error = new Error('Merge-Job kann erst ab mindestens 2 Discs wiederhergestellt werden.'); + error.statusCode = 409; + throw error; + } + + const missingOutputs = []; + for (const child of discChildren) { + const outputPath = String(child?.output_path || '').trim(); + if (!outputPath || !fs.existsSync(outputPath)) { + missingOutputs.push({ + jobId: Number(child?.id || 0) || null, + discNumber: resolveDiscNumberFromJobLike(child), + outputPath: outputPath || null + }); + } + } + if (missingOutputs.length > 0) { + const detail = missingOutputs + .map((item) => `Disc ${item.discNumber || '?'} / Job #${item.jobId || '?'}${item.outputPath ? ` (${item.outputPath})` : ''}`) + .join(' | '); + const error = new Error( + `Merge-Job kann nicht wiederhergestellt werden: Es fehlen Output-Dateien (${detail}).` + ); + error.statusCode = 409; + error.details = missingOutputs; + throw error; + } + + const upsertResult = await this.upsertMultipartMergeJobForContainer(normalizedContainerJobId, { + containerJob, + createIfMissing: true + }); + if (!upsertResult?.handled || !upsertResult?.mergeJobId) { + const error = new Error('Merge-Job konnte nicht wiederhergestellt werden.'); + error.statusCode = 409; + throw error; + } + return historyService.getJobById(upsertResult.mergeJobId); + } + + async analyzeMultipartMergeInputs(sourceItems = [], options = {}) { + const normalizedSourceItems = Array.isArray(sourceItems) ? sourceItems : []; + const ffprobeCommand = String(options?.ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; + const sourceAnalyses = []; + for (const sourceItem of normalizedSourceItems) { + const sourceJobId = this.normalizeQueueJobId(sourceItem?.jobId || sourceItem?.sourceJobId); + const discNumber = normalizePositiveInteger(sourceItem?.discNumber); + const outputPath = String(sourceItem?.outputPath || sourceItem?.path || '').trim(); + if (!sourceJobId || !outputPath) { + continue; + } + const sourceMeta = { + sourceJobId, + discNumber: discNumber || null, + outputPath + }; + if (!fs.existsSync(outputPath)) { + sourceAnalyses.push({ + ...sourceMeta, + error: 'output_missing' + }); + continue; + } + try { + const probeResult = await this.runCapturedCommand(ffprobeCommand, [ + '-v', 'error', + '-show_chapters', + '-show_streams', + '-show_format', + '-print_format', 'json', + outputPath + ]); + const probeJson = this.safeParseJson(probeResult.stdout) || {}; + sourceAnalyses.push({ + ...sourceMeta, + probeJson, + formatDurationSeconds: Number.parseFloat(String(probeJson?.format?.duration || '').trim()) + }); + } catch (error) { + sourceAnalyses.push({ + ...sourceMeta, + error: error?.message || 'ffprobe_failed' + }); + } + } + + const chapterSourceGroups = sourceAnalyses + .filter((entry) => !entry.error && entry.probeJson) + .map((entry) => { + const probeJson = entry.probeJson || {}; + const probeChapters = Array.isArray(probeJson?.chapters) ? probeJson.chapters : []; + let fallbackDurationNanoseconds = 0; + for (const chapter of probeChapters) { + const endNanoseconds = parseDecimalSecondsToNanoseconds(chapter?.end_time ?? chapter?.start_time); + if (endNanoseconds != null && endNanoseconds > fallbackDurationNanoseconds) { + fallbackDurationNanoseconds = endNanoseconds; + } + } + const hasDuration = Number.isFinite(entry.formatDurationSeconds) && entry.formatDurationSeconds > 0; + const durationSeconds = hasDuration + ? entry.formatDurationSeconds + : (fallbackDurationNanoseconds > 0 ? (fallbackDurationNanoseconds / 1_000_000_000) : null); + return { + discNumber: entry.discNumber, + chapters: probeChapters, + durationSeconds + }; + }); + + const allInputsAnalyzed = sourceAnalyses.length === normalizedSourceItems.length + && sourceAnalyses.every((entry) => !entry.error); + const chapterPlan = allInputsAnalyzed + ? buildMultipartMergedChapterPlan(chapterSourceGroups) + : null; + const chapterPlanReason = chapterPlan + ? null + : (allInputsAnalyzed ? 'chapter_data_unavailable' : 'ffprobe_failed_or_input_missing'); + + return { + sourceAnalyses, + chapterPlan, + chapterPlanReason, + streamSummary: buildMultipartMergeStreamSummary(sourceAnalyses) + }; + } + + async getMultipartMergePreview(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) + ? options.preloadedJob + : await historyService.getJobById(normalizedJobId); + if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { + const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + if (sourceItems.length < 2) { + const error = new Error('Merge-Vorschau nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); + error.statusCode = 409; + throw error; + } + + const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { + encodePlan: mergePlan, + mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; + const expectedOutputPath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); + const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content); + + const commandArgs = ['-o', expectedOutputPath]; + if (hasGeneratedChapterPlan) { + commandArgs.push('--chapters', ''); + } + sourceItems.forEach((item, index) => { + if (index > 0) { + commandArgs.push('+'); + } + if (hasGeneratedChapterPlan) { + commandArgs.push('--no-chapters'); + } + commandArgs.push(item.outputPath); + }); + + const missingInputs = sourceItems + .filter((item) => !fs.existsSync(item.outputPath)) + .map((item) => ({ + sourceJobId: item.jobId, + discNumber: item.discNumber || null, + outputPath: item.outputPath + })); + + return { + jobId: normalizedJobId, + generatedAt: nowIso(), + outputPath: expectedOutputPath, + sourceItems, + command: { + cmd: mkvmergeCommand, + args: commandArgs, + preview: buildShellCommandPreview(mkvmergeCommand, commandArgs) + }, + chapters: { + strategy: hasGeneratedChapterPlan ? 'generated_prefixed' : 'source_native', + reason: analysis?.chapterPlanReason || null, + count: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries.length : 0, + entries: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries : [] + }, + media: { + ...analysis.streamSummary + }, + validation: { + missingInputs + } + }; + } + + async startMultipartMergeJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + this.ensureNotBusy('startMultipartMergeJob', normalizedJobId); + const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) + ? options.preloadedJob + : await historyService.getJobById(normalizedJobId); + if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { + const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + if (sourceItems.length < 2) { + const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); + error.statusCode = 409; + throw error; + } + for (const item of sourceItems) { + if (!fs.existsSync(item.outputPath)) { + const error = new Error(`Merge-Input fehlt: ${item.outputPath}`); + error.statusCode = 409; + throw error; + } + } + + const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { + encodePlan: mergePlan, + mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, mergeJob, normalizedJobId); + const preferredFinalOutputPath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + ensureDir(path.dirname(incompleteOutputPath)); + + await this.setState('ENCODING', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: `Merge läuft (${sourceItems.length} Teile)`, + context: { + jobId: normalizedJobId, + mode: 'multipart_merge', + mediaProfile, + outputPath: incompleteOutputPath, + sourceItems + } + }); + await historyService.updateJob(normalizedJobId, { + status: 'ENCODING', + last_state: 'ENCODING', + output_path: incompleteOutputPath, + error_message: null, + end_time: null + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge gestartet. Quellen: ${sourceItems.map((item) => item.outputPath).join(' | ')}` + ); + + const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-')); + let chaptersFilePath = null; + + let runInfo = null; + try { + let chapterPreparationMode = 'source_native'; + let chapterPreparationEntries = 0; + const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); + const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses) + ? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error)) + : []; + if (erroredSources.length > 0) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).` + ); + } + const mergedChapterContent = String(mergeInputAnalysis?.chapterPlan?.content || '').trim(); + if (mergedChapterContent) { + chaptersFilePath = path.join(mergeTmpDir, `merge-job-${normalizedJobId}-chapters.txt`); + fs.writeFileSync(chaptersFilePath, `${mergedChapterContent}\n`, 'utf8'); + chapterPreparationMode = 'prefixed_from_inputs'; + chapterPreparationEntries = Array.isArray(mergeInputAnalysis?.chapterPlan?.entries) + ? mergeInputAnalysis.chapterPlan.entries.length + : 0; + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge-Kapitel vorbereitet (${chapterPreparationEntries} Einträge, Disc-Prefix aktiv).` + ); + } else { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'Merge-Kapitel konnten nicht normalisiert werden; verwende Kapitel direkt aus den Quellen.' + ); + } + + const mkvmergeArgs = ['-o', incompleteOutputPath]; + if (chaptersFilePath) { + mkvmergeArgs.push('--chapters', chaptersFilePath); + } + sourceItems.forEach((item, sourceIndex) => { + if (sourceIndex > 0) { + mkvmergeArgs.push('+'); + } + if (chaptersFilePath) { + mkvmergeArgs.push('--no-chapters'); + } + mkvmergeArgs.push(item.outputPath); + }); + + runInfo = await this.runCommand({ + jobId: normalizedJobId, + stage: 'ENCODING', + source: 'MKVMERGE', + cmd: mkvmergeCommand, + args: mkvmergeArgs + }); + 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 + } + }); + 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}` + }); + return { + started: true, + stage: 'ENCODING', + outputPath: 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); + error.jobAlreadyFailed = true; + throw error; + } finally { + try { + fs.rmSync(mergeTmpDir, { recursive: true, force: true }); + } catch (_error) { + // ignore tmp cleanup errors + } + } + } + isSeriesBatchChildJob(jobLike = null) { const plan = this.extractQueueJobPlan(jobLike); return isSeriesBatchChildPlan(plan); @@ -14417,6 +16213,7 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } + options = await this.resolveMultipartReviewLockOptions(job, options); if (!rawPath || !fs.existsSync(rawPath)) { const error = new Error(`RAW-Pfad nicht gefunden (${rawPath || '-'})`); @@ -14989,7 +16786,11 @@ class PipelineService extends EventEmitter { ? options.previousEncodePlan : null ); - review = reviewPrefillResult.plan; + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + review = multipartLockResult.plan; resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) || resolvedSelectedTitleIds[0] @@ -15044,6 +16845,14 @@ class PipelineService extends EventEmitter { + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` ); } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); const readyStatusText = review.titleSelectionRequired @@ -15651,7 +17460,11 @@ class PipelineService extends EventEmitter { ? options.previousEncodePlan : null ); - review = reviewPrefillResult.plan; + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + review = multipartLockResult.plan; if (!Array.isArray(review.titles) || review.titles.length === 0) { const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.'); @@ -15695,6 +17508,14 @@ class PipelineService extends EventEmitter { + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` ); } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } if (playlistDecisionRequired) { const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean); const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); @@ -16442,6 +18263,8 @@ class PipelineService extends EventEmitter { let multipartExistingJobId = null; let multipartCurrentDiscNumber = null; let multipartExistingDiscNumber = null; + let multipartExistingSelectedMetadataForRawRename = null; + let multipartExistingAnalyzeContextForRawRename = null; let shouldDetachMultipartContainer = false; if (isDiscFilmMetadataSelection) { @@ -16462,7 +18285,7 @@ class PipelineService extends EventEmitter { WHERE id != ? AND media_type = ? AND metadata_fingerprint = ? - AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, @@ -16476,7 +18299,7 @@ class PipelineService extends EventEmitter { WHERE id != ? AND media_type = ? AND LOWER(TRIM(COALESCE(imdb_id, ''))) = LOWER(TRIM(?)) - AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, @@ -16493,7 +18316,7 @@ class PipelineService extends EventEmitter { AND media_type = ? AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) AND CAST(COALESCE(year, 0) AS INTEGER) = ? - AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, @@ -16507,7 +18330,7 @@ class PipelineService extends EventEmitter { WHERE id != ? AND media_type = ? AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) - AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, @@ -16732,6 +18555,13 @@ class PipelineService extends EventEmitter { } } }, mediaProfile); + multipartExistingSelectedMetadataForRawRename = patchedExistingSelectedMetadata; + multipartExistingAnalyzeContextForRawRename = { + ...existingAnalyzeContext, + metadataProvider: 'omdb', + workflowKind: 'film', + selectedMetadata: patchedExistingSelectedMetadata + }; if (multipartExistingJobId) { await historyService.updateJob(multipartExistingJobId, { @@ -17001,19 +18831,86 @@ class PipelineService extends EventEmitter { } } const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const currentJobKind = String(job?.job_kind || '').trim().toLowerCase(); + const isCurrentJobMultipart = Number(job?.is_multipart_movie || 0) === 1 || currentJobKind === 'multipart_movie_child'; + const isMultipartMovieSelection = Boolean( + isDiscFilmMetadataSelection + && (multipartContainerJobId || normalizedDuplicateAction === 'multipart_movie' || isCurrentJobMultipart) + ); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; + + if (isMultipartMovieSelection && multipartExistingJobId && multipartExistingSelectedMetadataForRawRename) { + try { + const existingJobForRawRename = await historyService.getJobById(multipartExistingJobId).catch(() => null); + const existingStoredRawPath = String(existingJobForRawRename?.raw_path || '').trim(); + const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + existingStoredRawPath + ) || existingStoredRawPath || null; + if (resolvedExistingRawPath && fs.existsSync(resolvedExistingRawPath) && fs.statSync(resolvedExistingRawPath).isDirectory()) { + const existingRawState = resolveRawFolderStateFromPath(resolvedExistingRawPath); + const targetRawState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE + ? RAW_FOLDER_STATES.INCOMPLETE + : RAW_FOLDER_STATES.RIP_COMPLETE; + const existingMetadataBase = buildRawMetadataBase({ + title: existingJobForRawRename?.title || effectiveTitle || null, + year: existingJobForRawRename?.year || effectiveYear || null, + detected_title: existingJobForRawRename?.detected_title || effectiveTitle || null, + media_type: mediaProfile, + is_multipart_movie: 1, + job_kind: 'multipart_movie_child' + }, multipartExistingJobId, { + mediaProfile, + analyzeContext: multipartExistingAnalyzeContextForRawRename, + selectedMetadata: multipartExistingSelectedMetadataForRawRename, + isMultipartMovie: true + }); + const existingTargetRawPath = path.join( + path.dirname(resolvedExistingRawPath), + buildRawDirName(existingMetadataBase, multipartExistingJobId, { state: targetRawState }) + ); + if ( + normalizeComparablePath(resolvedExistingRawPath) !== normalizeComparablePath(existingTargetRawPath) + && !fs.existsSync(existingTargetRawPath) + ) { + fs.renameSync(resolvedExistingRawPath, existingTargetRawPath); + await historyService.updateRawPathByOldPath(resolvedExistingRawPath, existingTargetRawPath); + await historyService.appendLog( + multipartExistingJobId, + 'SYSTEM', + `Multipart-RAW umbenannt: ${resolvedExistingRawPath} -> ${existingTargetRawPath}` + ); + logger.info('metadata:multipart-existing-raw-renamed', { + jobId: multipartExistingJobId, + from: resolvedExistingRawPath, + to: existingTargetRawPath + }); + } + } + } catch (multipartRawRenameError) { + logger.warn('metadata:multipart-existing-raw-rename-failed', { + jobId: multipartExistingJobId, + error: errorToMeta(multipartRawRenameError) + }); + } + } + const metadataBase = buildRawMetadataBase({ title: selectedMetadata.title || job.detected_title || null, year: selectedMetadata.year || null, detected_title: job.detected_title || null, - media_type: mediaProfile + media_type: mediaProfile, + is_multipart_movie: isMultipartMovieSelection ? 1 : 0, + job_kind: isMultipartMovieSelection ? 'multipart_movie_child' : (job?.job_kind || null) }, jobId, { mediaProfile, analyzeContext: mkInfo?.analyzeContext || null, - selectedMetadata + selectedMetadata, + isMultipartMovie: isMultipartMovieSelection }); const rawStorage = resolveSeriesAwareRawStorage( settings, @@ -17027,7 +18924,13 @@ class PipelineService extends EventEmitter { || settingsService.DEFAULT_RAW_DIR || '' ).trim(); - const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); + const metadataMatchedRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); + const metadataMatchedRawJobId = metadataMatchedRawPath + ? extractRawFolderJobId(path.basename(metadataMatchedRawPath)) + : null; + const existingRawPath = isMultipartMovieSelection + ? (metadataMatchedRawJobId === Number(jobId) ? metadataMatchedRawPath : null) + : metadataMatchedRawPath; const resolvedCurrentJobRawPath = this.resolveCurrentRawPathForSettings( settings, mediaProfile, @@ -17074,7 +18977,7 @@ class PipelineService extends EventEmitter { const requiresManualPlaylistSelection = Boolean( playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); - const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob; + const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob && !isMultipartMovieSelection; const nextStatus = (requiresManualPlaylistSelection || requiresRawDecision) ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ @@ -17158,6 +19061,13 @@ class PipelineService extends EventEmitter { `Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartExistingDiscNumber || '-'}` ); } + await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-metadata-select-failed', { + containerJobId: multipartContainerJobId, + jobId, + error: errorToMeta(mergePrepError) + }); + }); } // Bild in Cache laden (async, blockiert nicht) @@ -17356,6 +19266,13 @@ class PipelineService extends EventEmitter { } return this.startPreparedConverterVideoJob(jobId, { ...options, preloadedJob }); } + if (this.isMultipartMovieMergeHistoryJob(preloadedJob)) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }) + ); + } const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { @@ -17464,6 +19381,10 @@ class PipelineService extends EventEmitter { await historyService.resetProcessLog(jobId); + if (this.isMultipartMovieMergeHistoryJob(job)) { + return this.startMultipartMergeJob(jobId, { ...options, preloadedJob: job }); + } + const encodePlanForReadyState = this.safeParseJson(job.encode_plan_json); const readyMode = String(encodePlanForReadyState?.mode || '').trim().toLowerCase(); const isPreRipReadyState = readyMode === 'pre_rip' || Boolean(encodePlanForReadyState?.preRip); @@ -17894,9 +19815,26 @@ class PipelineService extends EventEmitter { error.statusCode = 400; throw error; } + const multipartSettingsLock = encodePlan?.multipartSettingsLock && typeof encodePlan.multipartSettingsLock === 'object' + ? encodePlan.multipartSettingsLock + : null; + const multipartSettingsLocked = Boolean( + multipartSettingsLock?.enabled + && this.isMultipartMovieDiscChildHistoryJob(job) + ); - const selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null; - const selectedEncodeTitleIds = normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); + const lockedSelectedEncodeTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + const lockedSelectedEncodeTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [lockedSelectedEncodeTitleId] + ); + const selectedEncodeTitleId = multipartSettingsLocked + ? (lockedSelectedEncodeTitleId || null) + : (options?.selectedEncodeTitleId ?? null); + const selectedEncodeTitleIds = multipartSettingsLocked + ? lockedSelectedEncodeTitleIds + : normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0 ? selectedEncodeTitleIds : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []); @@ -17906,14 +19844,50 @@ class PipelineService extends EventEmitter { effectiveSelectedEncodeTitleIds ); let planForConfirm = planWithSelectionResult.plan; - const selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' - ? options.selectedTrackSelection - : null; const trackSelectionTargets = normalizeReviewTitleIdList( Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0 ? planForConfirm.selectedTitleIds : [planForConfirm?.encodeInputTitleId] ); + let selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' + ? options.selectedTrackSelection + : null; + if (multipartSettingsLocked) { + const manualSelectionByTitle = planForConfirm?.manualTrackSelectionByTitle + && typeof planForConfirm.manualTrackSelectionByTitle === 'object' + ? planForConfirm.manualTrackSelectionByTitle + : {}; + const lockedTrackSelectionPayload = {}; + for (const trackSelectionTitleId of trackSelectionTargets) { + const lockedEntry = manualSelectionByTitle[trackSelectionTitleId] + || manualSelectionByTitle[String(trackSelectionTitleId)] + || null; + if (lockedEntry && typeof lockedEntry === 'object') { + lockedTrackSelectionPayload[trackSelectionTitleId] = { + audioTrackIds: Array.isArray(lockedEntry.audioTrackIds) ? lockedEntry.audioTrackIds : [], + subtitleTrackIds: Array.isArray(lockedEntry.subtitleTrackIds) ? lockedEntry.subtitleTrackIds : [], + subtitleVariantSelection: lockedEntry.subtitleVariantSelection || {}, + subtitleLanguageOrder: Array.isArray(lockedEntry.subtitleLanguageOrder) ? lockedEntry.subtitleLanguageOrder : [] + }; + continue; + } + const fallbackSelection = extractManualSelectionPayloadFromPlan({ + ...planForConfirm, + encodeInputTitleId: trackSelectionTitleId + }); + if (fallbackSelection && typeof fallbackSelection === 'object') { + lockedTrackSelectionPayload[trackSelectionTitleId] = { + audioTrackIds: Array.isArray(fallbackSelection.audioTrackIds) ? fallbackSelection.audioTrackIds : [], + subtitleTrackIds: Array.isArray(fallbackSelection.subtitleTrackIds) ? fallbackSelection.subtitleTrackIds : [], + subtitleVariantSelection: fallbackSelection.subtitleVariantSelection || {}, + subtitleLanguageOrder: Array.isArray(fallbackSelection.subtitleLanguageOrder) ? fallbackSelection.subtitleLanguageOrder : [] + } + } + } + selectedTrackSelectionPayload = Object.keys(lockedTrackSelectionPayload).length > 0 + ? lockedTrackSelectionPayload + : null; + } const trackSelectionResults = []; if (selectedTrackSelectionPayload && trackSelectionTargets.length > 0) { for (const trackSelectionTitleId of trackSelectionTargets) { @@ -17965,7 +19939,9 @@ class PipelineService extends EventEmitter { subtitleSelectionValidationErrors: [] }; const normalizedEpisodeAssignments = normalizeEpisodeAssignmentsPayload( - options?.episodeAssignments, + multipartSettingsLocked + ? (planForConfirm?.episodeAssignments || null) + : options?.episodeAssignments, planForConfirm?.selectedTitleIds || [] ); const titleById = new Map( @@ -18030,7 +20006,7 @@ class PipelineService extends EventEmitter { titles: remappedTitlesWithEpisodes, episodeAssignments: normalizedEpisodeAssignmentsWithPath }; - const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined; + const hasExplicitPostScriptSelection = !multipartSettingsLocked && options?.selectedPostEncodeScriptIds !== undefined; const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); @@ -18038,18 +20014,18 @@ class PipelineService extends EventEmitter { strict: true }); - const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined; + const hasExplicitPreScriptSelection = !multipartSettingsLocked && options?.selectedPreEncodeScriptIds !== undefined; const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []); const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }); - const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined; + const hasExplicitPostChainSelection = !multipartSettingsLocked && options?.selectedPostEncodeChainIds !== undefined; const selectedPostEncodeChainIds = hasExplicitPostChainSelection ? normalizeChainIdList(options?.selectedPostEncodeChainIds || []) : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []); - const hasExplicitPreChainSelection = options?.selectedPreEncodeChainIds !== undefined; + const hasExplicitPreChainSelection = !multipartSettingsLocked && options?.selectedPreEncodeChainIds !== undefined; const selectedPreEncodeChainIds = hasExplicitPreChainSelection ? normalizeChainIdList(options?.selectedPreEncodeChainIds || []) : normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []); @@ -18064,8 +20040,10 @@ class PipelineService extends EventEmitter { } // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. - const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); - const hasExplicitHandBrakePresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); + const hasExplicitUserPresetSelection = !multipartSettingsLocked + && Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); + const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked + && Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); const rawHandBrakePreset = hasExplicitHandBrakePresetSelection ? String(options?.selectedHandBrakePreset || '').trim() : ''; @@ -18179,6 +20157,9 @@ class PipelineService extends EventEmitter { + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.` + + (multipartSettingsLocked + ? ` Multipart-Lock aktiv${multipartSettingsLock?.sourceJobId ? ` (Quelle Job #${multipartSettingsLock.sourceJobId}` : ''}${multipartSettingsLock?.sourceDiscNumber ? `${multipartSettingsLock?.sourceJobId ? ', ' : ' ('}Disc ${multipartSettingsLock.sourceDiscNumber}` : ''}${multipartSettingsLock?.sourceJobId || multipartSettingsLock?.sourceDiscNumber ? ')' : ''}.` + : '') + (resolvedUserPreset ? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.` : '') @@ -18844,6 +20825,7 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } + options = await this.resolveMultipartReviewLockOptions(job, options); const mkInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { @@ -19335,7 +21317,11 @@ class PipelineService extends EventEmitter { ? options.previousEncodePlan : null ); - enrichedReview = reviewPrefillResult.plan; + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + enrichedReview = multipartLockResult.plan; const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null; @@ -20147,6 +22133,14 @@ class PipelineService extends EventEmitter { + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` ); } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } const mediainfoReadyStatusText = titleSelectionRequired ? 'Mediainfo geprüft - Titelauswahl per Checkbox erforderlich' @@ -21310,12 +23304,32 @@ class PipelineService extends EventEmitter { }; const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan); let finalizedRawPath = activeRawPath || null; - const deferRawFinalizeUntilSeriesBatchDone = isSeriesBatchEpisodeRun || Boolean(seriesBatchParentJobId); + const isMultipartDiscChildRun = this.isMultipartMovieDiscChildHistoryJob(job); + const deferRawFinalizeUntilSeriesBatchDone = ( + isSeriesBatchEpisodeRun + || Boolean(seriesBatchParentJobId) + ) && !isMultipartDiscChildRun; if (activeRawPath && !deferRawFinalizeUntilSeriesBatchDone) { const currentRawPath = String(activeRawPath || '').trim(); const completedRawPath = buildCompletedRawPath(currentRawPath); if (completedRawPath && completedRawPath !== currentRawPath) { - if (fs.existsSync(completedRawPath)) { + const currentRawExists = fs.existsSync(currentRawPath); + const completedRawExists = fs.existsSync(completedRawPath); + if (!currentRawExists && completedRawExists) { + finalizedRawPath = completedRawPath; + await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Pfad nach Encode korrigiert (bereits finalisiert): ${currentRawPath} -> ${completedRawPath}` + ); + } else if (!currentRawExists) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Encode nicht finalisiert werden (Quellpfad fehlt): ${currentRawPath}` + ); + } else if (completedRawExists) { logger.warn('encoding:raw-dir-finalize:target-exists', { jobId, sourceRawPath: currentRawPath, @@ -21393,9 +23407,22 @@ class PipelineService extends EventEmitter { }); if (job?.parent_job_id) { - await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { + const parentJobId = this.normalizeQueueJobId(job.parent_job_id); + const parentJob = parentJobId + ? await historyService.getJobById(parentJobId).catch(() => null) + : null; + if (parentJob && this.isMultipartMovieContainerHistoryJob(parentJob) && this.isMultipartMovieDiscChildHistoryJob(job)) { + await this.upsertMultipartMergeJobForContainer(parentJobId, { containerJob: parentJob }).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-child-finish-failed', { + parentJobId, + childJobId: jobId, + error: errorToMeta(mergePrepError) + }); + }); + } + await this.syncSeriesContainerStatusFromChildren(parentJobId).catch((parentSyncError) => { logger.warn('series-container:status-sync-on-child-finish-failed', { - parentJobId: job.parent_job_id, + parentJobId, childJobId: jobId, error: errorToMeta(parentSyncError) }); @@ -21572,11 +23599,16 @@ class PipelineService extends EventEmitter { title: job.title || job.detected_title || null, year: job.year || null, detected_title: job.detected_title || null, - media_type: mediaProfile + media_type: mediaProfile, + is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: job?.job_kind || null }, jobId, { mediaProfile, analyzeContext, - selectedMetadata + selectedMetadata, + isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' }); const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); const rawJobDir = path.join(effectiveRawBaseDir, rawDirName); @@ -25643,11 +27675,16 @@ class PipelineService extends EventEmitter { title: job.title || job.detected_title || null, year: job.year || null, detected_title: job.detected_title || null, - media_type: mediaProfile + media_type: mediaProfile, + is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: job?.job_kind || null }, jobId, { mediaProfile, analyzeContext: mkInfo?.analyzeContext || null, - selectedMetadata + selectedMetadata, + isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' }); const currentState = resolveRawFolderStateFromPath(currentRawPath); const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 3d29931..538f819 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -43,6 +43,15 @@ const SENSITIVE_SETTING_KEYS = new Set([ 'pushover_token', 'pushover_user' ]); +const IMMUTABLE_SETTING_KEYS = new Set([ + 'makemkv_command', + 'mediainfo_command', + 'handbrake_command', + 'ffmpeg_command', + 'ffprobe_command', + 'cdparanoia_command', + 'mkvmerge_command' +]); const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']); const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']); const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']); @@ -159,6 +168,10 @@ function applyRuntimeLogDirSetting(rawValue) { } } +function isImmutableSettingKey(key) { + return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()); +} + function normalizeTrackIds(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); @@ -961,6 +974,12 @@ class SettingsService { } async setSettingValue(key, rawValue) { + if (isImmutableSettingKey(key)) { + const error = new Error(`Setting ${key} ist schreibgeschützt und kann nicht geändert werden.`); + error.statusCode = 403; + throw error; + } + const db = await getDb(); const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]); if (!schema) { @@ -1022,6 +1041,14 @@ class SettingsService { const validationErrors = []; for (const [key, rawValue] of entries) { + if (isImmutableSettingKey(key)) { + validationErrors.push({ + key, + message: 'Dieses Setting ist schreibgeschützt und kann nicht geändert werden.' + }); + continue; + } + const schema = schemaByKey.get(key); if (!schema) { const error = new Error(`Setting ${key} existiert nicht.`); @@ -1047,7 +1074,9 @@ class SettingsService { if (validationErrors.length > 0) { const error = new Error('Mindestens ein Setting ist ungültig.'); - error.statusCode = 400; + error.statusCode = validationErrors.some((item) => String(item?.message || '').includes('schreibgeschützt')) + ? 403 + : 400; error.details = validationErrors; throw error; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c1ad303..330d000 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.14.0-4", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.14.0-4", + "version": "0.15.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 92dbb91..1bbc35f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.14.0-4", + "version": "0.15.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d968f23..af3ee9f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -455,6 +455,8 @@ function App() { ...(prev || {}), queue: message.payload || null })); + setRipperJobsRefreshToken((prev) => prev + 1); + setHistoryJobsRefreshToken((prev) => prev + 1); } if (message.type === 'DISC_DETECTED') { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 1afd743..751c2c4 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -640,6 +640,26 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, + async reorderMultipartMergeSources(jobId, orderedSourceJobIds = []) { + const result = await request(`/pipeline/multipart-merge/${jobId}/reorder`, { + method: 'POST', + body: JSON.stringify({ + orderedSourceJobIds: Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : [] + }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getMultipartMergePreview(jobId) { + return request(`/pipeline/multipart-merge/${jobId}/preview`); + }, + async restoreMultipartMergeJob(containerJobId) { + const result = await request(`/pipeline/multipart-merge/${containerJobId}/restore`, { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, async confirmEncodeReview(jobId, payload = {}) { const result = await request(`/pipeline/confirm-encode/${jobId}`, { method: 'POST', diff --git a/frontend/src/assets/media-merge.svg b/frontend/src/assets/media-merge.svg new file mode 100644 index 0000000..4d4e986 --- /dev/null +++ b/frontend/src/assets/media-merge.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index e19c671..60d079f 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -25,6 +25,7 @@ const GENERAL_TOOL_KEYS = new Set([ 'mediainfo_command', 'handbrake_command', 'ffmpeg_command', + 'mkvmerge_command', 'ffprobe_command', 'handbrake_restart_delete_incomplete_output', 'script_test_timeout_ms' @@ -530,6 +531,15 @@ const CONVERTER_SCAN_EXTENSION_OPTIONS = [ 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' ]; +const READONLY_CLI_SETTING_KEYS = new Set([ + 'makemkv_command', + 'mediainfo_command', + 'handbrake_command', + 'ffmpeg_command', + 'ffprobe_command', + 'cdparanoia_command', + 'mkvmerge_command' +]); function parseConverterScanExtensions(value) { const tokens = String(value || '') @@ -630,6 +640,10 @@ function isNotificationEventToggleSetting(setting) { } function isReadonlySetting(setting) { + const key = normalizeSettingKey(setting?.key); + if (READONLY_CLI_SETTING_KEYS.has(key)) { + return true; + } try { // API gibt validation als parsed object zurück, nicht validation_json als String const v = setting?.validation; @@ -656,6 +670,8 @@ function SettingField({ const ownerKey = ownerSetting?.key; const pathHasValue = Boolean(String(value ?? '').trim()); const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean'; + const readonlySetting = isReadonlySetting(setting); + const isDisabled = Boolean(disabled || readonlySetting); return (
@@ -668,15 +684,17 @@ function SettingField({ onChange?.(setting.key, event.value)} + disabled={isDisabled} + onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)} />
) : ( -