diff --git a/backend/package-lock.json b/backend/package-lock.json index a7fa113..8e19dee 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.15.0-1", + "version": "0.15.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.15.0-1", + "version": "0.15.0-2", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 89714ef..be607cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.15.0-1", + "version": "0.15.0-2", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index f32a738..4c7328e 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -770,6 +770,8 @@ async function migrateOutputTemplates(db) { async function removeDeprecatedSettings(db) { const deprecatedKeys = [ 'pushover_notify_disc_detected', + 'pushover_notify_cron_success', + 'pushover_notify_cron_error', 'mediainfo_extra_args', 'makemkv_rip_mode', 'makemkv_analyze_extra_args', diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index a885eb1..edcb8d9 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -591,6 +591,32 @@ function toProcessLogPath(jobId) { return path.join(getJobLogDir(), `job-${Math.trunc(normalizedId)}.process.log`); } +function buildArchivedProcessLogPath(sourceJobId, options = {}) { + const normalizedId = Number(sourceJobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + const targetJobId = Number(options?.replacementJobId); + const safeReason = String(options?.reason || 'retired') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'retired'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const archiveDir = path.join(getJobLogDir(), 'archived'); + const targetSuffix = Number.isFinite(targetJobId) && targetJobId > 0 + ? `.to-${Math.trunc(targetJobId)}` + : ''; + return { + archiveDir, + archivePath: path.join( + archiveDir, + `job-${Math.trunc(normalizedId)}.process.${safeReason}${targetSuffix}.${timestamp}.log` + ) + }; +} + function hasProcessLogFile(jobId) { const filePath = toProcessLogPath(jobId); return Boolean(filePath && fs.existsSync(filePath)); @@ -2944,7 +2970,17 @@ class HistoryService { } await this.closeProcessLog(fromJobId); - this._deleteProcessLogFile(fromJobId); + const archivedProcessLogPath = this._archiveProcessLogFile(fromJobId, { + replacementJobId: toJobId, + reason + }); + if (archivedProcessLogPath) { + this.appendProcessLog( + toJobId, + 'SYSTEM', + `Vorheriger Prozess-Log von Job #${fromJobId} archiviert: ${archivedProcessLogPath}` + ); + } logger.warn('job:retired', { sourceJobId: fromJobId, @@ -2957,7 +2993,8 @@ class HistoryService { retired: true, sourceJobId: fromJobId, replacementJobId: toJobId, - reason + reason, + archivedProcessLogPath: archivedProcessLogPath || null }; } @@ -6708,6 +6745,33 @@ class HistoryService { } } + _archiveProcessLogFile(jobId, options = {}) { + const processLogPath = toProcessLogPath(jobId); + if (!processLogPath || !fs.existsSync(processLogPath)) { + return null; + } + const archiveTarget = buildArchivedProcessLogPath(jobId, options); + if (!archiveTarget?.archivePath) { + return null; + } + + try { + fs.mkdirSync(archiveTarget.archiveDir, { recursive: true }); + fs.renameSync(processLogPath, archiveTarget.archivePath); + return archiveTarget.archivePath; + } catch (error) { + logger.warn('job:process-log:archive-failed', { + jobId, + fromPath: processLogPath, + toPath: archiveTarget.archivePath, + reason: String(options?.reason || '').trim() || null, + replacementJobId: Number(options?.replacementJobId) || null, + error: error?.message || String(error) + }); + return null; + } + } + async deleteJobFiles(jobId, target = 'both', options = {}) { const allowedTargets = new Set(['raw', 'movie', 'both']); if (!allowedTargets.has(target)) { @@ -6992,9 +7056,12 @@ class HistoryService { movieCandidatePathForTracking = outputPath; } else { const parentDir = normalizeComparablePath(path.dirname(outputPath)); + const parentDirName = String(path.basename(parentDir || '') || '').trim(); + const isIncompleteMergeParentDir = /^incomplete_merge_.+_job_\d+\s*$/i.test(parentDirName); // Converter jobs output a single file — never delete the parent dir const isConverterJob = resolvedPaths.mediaType === 'converter'; const canDeleteParentDir = !isConverterJob + && !isIncompleteMergeParentDir && parentDir && parentDir !== movieRoot && isPathInside(movieRoot, parentDir) @@ -7388,11 +7455,26 @@ class HistoryService { }; const collectIncompleteMoviePathsFromPreview = (preview) => { const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []; + const incompleteJobPattern = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i; + const incompleteMergePattern = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i; return rows .filter((row) => Boolean(row?.exists)) + .filter((row) => { + const candidatePath = String(row?.path || '').trim(); + if (!candidatePath) { + return false; + } + if (incompleteJobPattern.test(candidatePath)) { + return true; + } + if (incompleteMergePattern.test(candidatePath)) { + // Shared multipart merge folders must never be auto-selected as a whole directory. + return Boolean(row?.isFile); + } + return false; + }) .map((row) => String(row?.path || '').trim()) - .filter((candidatePath) => Boolean(candidatePath)) - .filter((candidatePath) => /(^|[\\/])incomplete_job-\d+([\\/]|$)/i.test(candidatePath)); + .filter(Boolean); }; const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => { const normalizedOwnerJobIds = Array.from(new Set( diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index e485e8e..5abd16e 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -2035,6 +2035,98 @@ function normalizeMultipartMergeSourceItemsFromPlan(plan = null) { return ordered; } +function normalizeIncompleteMergeTitleToken(value, fallback = 'movie') { + const normalizedFallback = sanitizeFileName(String(fallback || '').trim() || 'movie') || 'movie'; + const normalized = sanitizeFileName(String(value || '').trim()); + if (!normalized) { + return normalizedFallback; + } + const compacted = normalized.replace(/\s+/g, '_'); + return compacted || normalizedFallback; +} + +function resolveMultipartContainerJobIdFromJob(job = null, fallbackContainerJobId = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const directContainerJobId = normalizePositiveInteger( + fallbackContainerJobId + || ( + String(job?.job_kind || job?.jobKind || '').trim().toLowerCase() === 'multipart_movie_container' + ? job?.id + : null + ) + || job?.parent_job_id + || encodePlan?.containerJobId + ); + return directContainerJobId || null; +} + +function buildIncompleteMergeFolderNameFromJob(job = null, options = {}) { + const fallbackJobId = normalizePositiveInteger(options?.fallbackJobId); + const containerJobId = resolveMultipartContainerJobIdFromJob( + job, + normalizePositiveInteger(options?.containerJobId) + ); + const titleFromContainer = String(options?.containerTitle || '').trim(); + const titleFromJob = String(job?.title || job?.detected_title || '').trim(); + const titleValue = titleFromContainer || titleFromJob || (fallbackJobId ? `job-${fallbackJobId}` : 'movie'); + const titleToken = normalizeIncompleteMergeTitleToken(titleValue, fallbackJobId ? `job-${fallbackJobId}` : 'movie'); + const containerToken = containerJobId || 'unknown'; + return `Incomplete_merge_${titleToken}_job_${containerToken}`; +} + +function buildIncompleteMergeOutputPathFromJob( + settings, + job, + preferredFinalOutputPath, + fallbackJobId = null, + options = {} +) { + const normalizedFinalPath = String(preferredFinalOutputPath || '').trim(); + const fallbackFinalPath = normalizedFinalPath || buildFinalOutputPathFromJob(settings, job, fallbackJobId); + const fallbackFileName = String(path.basename(fallbackFinalPath || '') || '').trim(); + const finalFileName = fallbackFileName || ( + normalizePositiveInteger(fallbackJobId) + ? `job-${normalizePositiveInteger(fallbackJobId)}.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` + : `job-unknown.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` + ); + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + const movieDir = seriesParts?.rootDir || settings.movie_dir; + const incompleteMergeFolderName = buildIncompleteMergeFolderNameFromJob(job, { + fallbackJobId, + containerJobId: options?.containerJobId, + containerTitle: options?.containerTitle + }); + return path.join(movieDir, incompleteMergeFolderName, finalFileName); +} + +function areMultipartMergeSourceItemsEquivalent(leftItems = [], rightItems = []) { + const left = Array.isArray(leftItems) ? leftItems : []; + const right = Array.isArray(rightItems) ? rightItems : []; + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index] || {}; + const rightItem = right[index] || {}; + const leftJobId = normalizePositiveInteger(leftItem.jobId || leftItem.sourceJobId); + const rightJobId = normalizePositiveInteger(rightItem.jobId || rightItem.sourceJobId); + if (leftJobId !== rightJobId) { + return false; + } + const leftDiscNumber = normalizePositiveInteger(leftItem.discNumber); + const rightDiscNumber = normalizePositiveInteger(rightItem.discNumber); + if (leftDiscNumber !== rightDiscNumber) { + return false; + } + const leftOutputPath = normalizeComparablePath(leftItem.outputPath || leftItem.path || null); + const rightOutputPath = normalizeComparablePath(rightItem.outputPath || rightItem.path || null); + if (leftOutputPath !== rightOutputPath) { + return false; + } + } + return true; +} + function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); const outputSuffix = jobKind === 'multipart_movie_merge' @@ -2077,20 +2169,23 @@ function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`); } -function buildMultipartMergeOutputPath(templateOutputPath, sourceItems = []) { +function buildMultipartMergeOutputPath(templateOutputPath, sourceItems = [], options = {}) { const normalizedTemplateOutputPath = String(templateOutputPath || '').trim(); if (!normalizedTemplateOutputPath) { return null; } + const preferSourceDirectory = options?.preferSourceDirectory === true; const templateParsed = path.parse(normalizedTemplateOutputPath); const fallbackDir = String(templateParsed.dir || '').trim() || process.cwd(); - const sourceDirs = Array.from(new Set( - (Array.isArray(sourceItems) ? sourceItems : []) - .map((item) => String(path.dirname(String(item?.outputPath || '').trim()) || '').trim()) - .filter(Boolean) - )); - const preferredDir = sourceDirs[0] || fallbackDir; + const sourceDirs = preferSourceDirectory + ? Array.from(new Set( + (Array.isArray(sourceItems) ? sourceItems : []) + .map((item) => String(path.dirname(String(item?.outputPath || '').trim()) || '').trim()) + .filter(Boolean) + )) + : []; + const preferredDir = (preferSourceDirectory ? (sourceDirs[0] || fallbackDir) : fallbackDir); const preferredExt = String(templateParsed.ext || '').trim() || '.mkv'; const preferredName = String(templateParsed.name || '').trim() || 'merged_output'; return path.join(preferredDir, `${preferredName}${preferredExt}`); @@ -2404,8 +2499,12 @@ function extractProgressDetail(source, line) { function composeStatusText(stage, percent, detail) { const normalizedStage = String(stage || '').trim().toUpperCase(); const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage; + const parsedPercent = Number(percent); + const wholePercent = Number.isFinite(parsedPercent) + ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) + : null; const base = percent !== null && percent !== undefined - ? `${baseLabel} ${percent.toFixed(2)}%` + ? `${baseLabel} ${wholePercent ?? 0}%` : baseLabel; if (detail) { @@ -2426,12 +2525,16 @@ function clampProgressPercent(value) { function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) { const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode'; const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript'; + const parsedPercent = Number(percent); + const wholePercent = Number.isFinite(parsedPercent) + ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) + : 0; const position = Number.isFinite(index) && Number.isFinite(total) && total > 0 ? ` ${index}/${total}` : ''; const status = statusWord ? ` ${statusWord}` : ''; const detail = String(label || '').trim(); - return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`; + return `ENCODING ${wholePercent}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`; } function parseMakeMkvMessageCode(line) { @@ -5926,6 +6029,56 @@ function extractSeriesSeasonDiscFromRawFolderName(folderName) { }; } +function extractMultipartFilmDiscFromRawFolderName(folderName) { + const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); + if (!baseName) { + return null; + } + const withoutJobSuffix = baseName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim(); + if (!withoutJobSuffix) { + return null; + } + const match = withoutJobSuffix.match(/\s-\sD(\d{1,3})\s*$/i); + return normalizePositiveInteger(match?.[1] || null); +} + +function resolveMultipartDiscNumberPair(options = {}) { + const preferredOrphanDiscNumber = normalizePositiveInteger(options?.preferredOrphanDiscNumber); + const preferredCurrentDiscNumber = normalizePositiveInteger(options?.preferredCurrentDiscNumber); + const orphanDiscNumber = preferredOrphanDiscNumber || 1; + let currentDiscNumber = preferredCurrentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1); + if (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber = orphanDiscNumber === 1 ? 2 : 1; + while (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber += 1; + } + } + return { + orphanDiscNumber, + currentDiscNumber + }; +} + +function normalizeMultipartMovieSelectedMetadata(baseSelectedMetadata = {}, discNumber = null) { + const source = baseSelectedMetadata && typeof baseSelectedMetadata === 'object' + ? baseSelectedMetadata + : {}; + const normalizedDiscNumber = normalizePositiveInteger(discNumber); + return { + ...source, + workflowKind: 'film', + metadataProvider: 'omdb', + metadataKind: 'movie', + providerId: null, + tmdbId: null, + seasonNumber: null, + seasonName: null, + episodeCount: 0, + episodes: [], + discNumber: normalizedDiscNumber || null + }; +} + function applyRawFolderStateToName(folderName, state) { const baseName = stripRawStatePrefix(folderName); if (!baseName) { @@ -9133,6 +9286,18 @@ class PipelineService extends EventEmitter { return { mediaProfile, resolvedRawPath, + rawState: resolveRawFolderStateFromPath(resolvedRawPath), + inputPath: null, + hasUsableRawInput: false + }; + } + + const rawState = resolveRawFolderStateFromPath(resolvedRawPath); + if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { + return { + mediaProfile, + resolvedRawPath, + rawState, inputPath: null, hasUsableRawInput: false }; @@ -9156,11 +9321,133 @@ class PipelineService extends EventEmitter { return { mediaProfile, resolvedRawPath, + rawState, inputPath, hasUsableRawInput: Boolean(inputPath) }; } + async validateStartupEncodeRecoveryReadiness(job = null) { + const resolvedJob = job && typeof job === 'object' ? job : null; + const jobId = this.normalizeQueueJobId(resolvedJob?.id); + if (!jobId) { + return { + ok: false, + code: 'invalid_job', + message: 'Startup-Recovery nicht möglich: ungültiger Job.' + }; + } + + const mkInfo = this.safeParseJson(resolvedJob?.makemkv_info_json); + const encodePlan = this.safeParseJson(resolvedJob?.encode_plan_json); + const sourceTag = String(mkInfo?.source || '').trim().toLowerCase(); + const mediaProfile = this.resolveMediaProfileForJob(resolvedJob, { + makemkvInfo: mkInfo, + encodePlan + }); + const isOrphanRawImportJob = sourceTag === 'orphan_raw_import'; + const isDiscRipJob = isSeriesDiscMediaProfile(mediaProfile) && !isOrphanRawImportJob; + + if (!isDiscRipJob) { + return { + ok: true, + code: 'not_disc_rip_job', + mediaProfile, + resolvedRawPath: String(resolvedJob?.raw_path || '').trim() || null, + rawState: resolveRawFolderStateFromPath(resolvedJob?.raw_path) + }; + } + + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + resolvedJob?.raw_path + ) || String(resolvedJob?.raw_path || '').trim() || null; + if (!resolvedRawPath) { + return { + ok: false, + code: 'missing_raw_path', + mediaProfile, + resolvedRawPath: null, + rawState: RAW_FOLDER_STATES.INCOMPLETE, + message: `Server-Neustart: Rip ist unvollständig (RAW-Pfad fehlt). Bitte Rip vom Laufwerk neu starten.` + }; + } + + let rawExists = false; + let rawIsDirectory = false; + try { + rawExists = fs.existsSync(resolvedRawPath); + rawIsDirectory = rawExists && fs.statSync(resolvedRawPath).isDirectory(); + } catch (_error) { + rawExists = false; + rawIsDirectory = false; + } + if (!rawExists || !rawIsDirectory) { + return { + ok: false, + code: 'raw_path_missing_on_disk', + mediaProfile, + resolvedRawPath, + rawState: resolveRawFolderStateFromPath(resolvedRawPath), + message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner fehlt: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + const rawState = resolveRawFolderStateFromPath(resolvedRawPath); + if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { + return { + ok: false, + code: 'raw_state_incomplete', + mediaProfile, + resolvedRawPath, + rawState, + message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner ist als Incomplete markiert: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + const ripMarkedSuccessful = Number(resolvedJob?.rip_successful || 0) === 1 + || String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (!ripMarkedSuccessful) { + return { + ok: false, + code: 'rip_not_marked_successful', + mediaProfile, + resolvedRawPath, + rawState, + message: 'Server-Neustart: Rip ist nicht als erfolgreich markiert. Bitte Rip vom Laufwerk neu starten.' + }; + } + + let hasUsableRawInput = false; + try { + hasUsableRawInput = Boolean( + hasBluRayBackupStructure(resolvedRawPath) || findPreferredRawInput(resolvedRawPath) + ); + } catch (_error) { + hasUsableRawInput = false; + } + if (!hasUsableRawInput) { + return { + ok: false, + code: 'raw_input_missing', + mediaProfile, + resolvedRawPath, + rawState, + message: `Server-Neustart: Rip enthält keine verwertbaren Dateien (${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + return { + ok: true, + code: 'ok', + mediaProfile, + resolvedRawPath, + rawState + }; + } + async alignRawFolderJobId(rawPath, targetJobId) { const sourceRawPath = String(rawPath || '').trim(); const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId); @@ -9348,12 +9635,6 @@ class PipelineService extends EventEmitter { continue; } - const ripSuccessful = this.isRipSuccessful(row); - if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) { - await historyService.updateJob(jobId, { rip_successful: 1 }); - ripFlagUpdateCount += 1; - } - const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs) || discoveredByJobId.get(jobId)?.path || null; @@ -9364,6 +9645,24 @@ class PipelineService extends EventEmitter { addLinkedRawPath(row.raw_path); addLinkedRawPath(currentRawPath); + const currentRawState = resolveRawFolderStateFromPath(currentRawPath); + let hasUsableRawInput = false; + try { + hasUsableRawInput = Boolean( + hasBluRayBackupStructure(currentRawPath) || findPreferredRawInput(currentRawPath) + ); + } catch (_error) { + hasUsableRawInput = false; + } + const ripSuccessful = this.isRipSuccessful(row); + const strictRipSuccessful = ripSuccessful + && currentRawState !== RAW_FOLDER_STATES.INCOMPLETE + && hasUsableRawInput; + if (strictRipSuccessful && Number(row?.rip_successful || 0) !== 1) { + await historyService.updateJob(jobId, { rip_successful: 1 }); + ripFlagUpdateCount += 1; + } + // Keep renamed folder in the same base dir as the current path const currentBaseDir = path.dirname(currentRawPath); const currentFolderName = stripRawStatePrefix(path.basename(currentRawPath)); @@ -9392,7 +9691,9 @@ class PipelineService extends EventEmitter { seriesSeasonNumber: folderSeriesSeasonDisc.seasonNumber, seriesDiscNumber: folderSeriesSeasonDisc.discNumber }); - const desiredRawFolderState = this.resolveDesiredRawFolderState(row); + const desiredRawFolderState = strictRipSuccessful + ? (this.isEncodeSuccessful(row) ? RAW_FOLDER_STATES.COMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE) + : RAW_FOLDER_STATES.INCOMPLETE; const desiredRawPath = path.join( currentBaseDir, buildRawDirName(metadataBase, jobId, { state: desiredRawFolderState }) @@ -9625,7 +9926,7 @@ class PipelineService extends EventEmitter { async recoverStaleRunningJobsOnStartup(db) { const staleRows = await db.all(` - SELECT id, status, last_state + SELECT id, status, last_state, raw_path, rip_successful, makemkv_info_json, encode_plan_json, media_type, job_kind FROM jobs WHERE COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') AND status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', @@ -9638,12 +9939,14 @@ class PipelineService extends EventEmitter { scanned: 0, preparedReadyToEncode: 0, markedError: 0, + blockedIncompleteRip: 0, skipped: 0 }; } let preparedReadyToEncode = 0; let markedError = 0; + let blockedIncompleteRip = 0; let skipped = 0; for (const row of rows) { @@ -9657,6 +9960,41 @@ class PipelineService extends EventEmitter { const message = `Server-Neustart erkannt während ${stage}. Laufender Prozess wurde beendet.`; if (stage === 'ENCODING') { + let recoveryValidation; + try { + recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row); + } catch (validationError) { + logger.warn('startup:recover-stale-encoding:validation-failed', { + jobId, + error: errorToMeta(validationError) + }); + recoveryValidation = { + ok: false, + code: 'validation_failed', + message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + if (!recoveryValidation?.ok) { + const recoveryMessage = String(recoveryValidation?.message || '').trim() + || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`; + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: 'RIPPING', + end_time: nowIso(), + error_message: recoveryMessage, + rip_successful: 0 + }); + try { + await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage); + } catch (_error) { + // keep recovery path even if log append fails + } + markedError += 1; + blockedIncompleteRip += 1; + continue; + } + try { await historyService.appendLog(jobId, 'SYSTEM', message); } catch (_error) { @@ -9732,12 +10070,14 @@ class PipelineService extends EventEmitter { scanned: rows.length, preparedReadyToEncode, markedError, + blockedIncompleteRip, skipped }); return { scanned: rows.length, preparedReadyToEncode, markedError, + blockedIncompleteRip, skipped }; } @@ -11242,6 +11582,11 @@ class PipelineService extends EventEmitter { }; } + isMultipartMergeJobCompletedState(jobLike = null) { + const state = String(jobLike?.status || jobLike?.last_state || '').trim().toUpperCase(); + return state === 'FINISHED'; + } + async normalizeMultipartOutputsOnStartup(db = null) { const database = db || await getDb(); const rows = await database.all(` @@ -11271,6 +11616,7 @@ class PipelineService extends EventEmitter { `); const childRows = Array.isArray(rows) ? rows : []; if (childRows.length === 0) { + const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); return { scanned: 0, updated: 0, @@ -11278,7 +11624,8 @@ class PipelineService extends EventEmitter { skipped: 0, failed: 0, mergePlansRefreshed: 0, - mergePlanRefreshFailed: 0 + mergePlanRefreshFailed: 0, + mergeOutputs: mergeOutputNormalization }; } @@ -11314,10 +11661,32 @@ class PipelineService extends EventEmitter { continue; } refreshContainerIds.add(parentJobId); + 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 + }); + } try { + let rowUpdated = false; const result = await this.ensureMultipartChildOutputHasDiscSuffix(childJobId, { childJob: row }); if (result?.updated) { + rowUpdated = true; + } + const refreshedChild = result?.updated + ? await historyService.getJobById(childJobId).catch(() => ({ ...row, output_path: result?.outputPath || row.output_path })) + : row; + const incompleteMergeResult = await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(childJobId, { + childJob: refreshedChild, + parentJob: containerRowsById.get(parentJobId) || null + }); + if (incompleteMergeResult?.updated) { + rowUpdated = true; + } + if (rowUpdated) { updated += 1; } else { unchanged += 1; @@ -11331,15 +11700,6 @@ class PipelineService extends EventEmitter { 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) { @@ -11358,7 +11718,17 @@ class PipelineService extends EventEmitter { } } - if (updated > 0 || failed > 0 || skipped > 0 || mergePlansRefreshed > 0 || mergePlanRefreshFailed > 0) { + const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); + + if ( + updated > 0 + || failed > 0 + || skipped > 0 + || mergePlansRefreshed > 0 + || mergePlanRefreshFailed > 0 + || Number(mergeOutputNormalization?.updated || 0) > 0 + || Number(mergeOutputNormalization?.failed || 0) > 0 + ) { logger.info('startup:multipart-output-normalize:done', { scanned: childRows.length, updated, @@ -11366,7 +11736,8 @@ class PipelineService extends EventEmitter { skipped, failed, mergePlansRefreshed, - mergePlanRefreshFailed + mergePlanRefreshFailed, + mergeOutputs: mergeOutputNormalization }); } @@ -11377,7 +11748,8 @@ class PipelineService extends EventEmitter { skipped, failed, mergePlansRefreshed, - mergePlanRefreshFailed + mergePlanRefreshFailed, + mergeOutputs: mergeOutputNormalization }; } @@ -11466,6 +11838,246 @@ class PipelineService extends EventEmitter { }; } + async ensureMultipartChildOutputUsesIncompleteMergePrefix(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 parentJobId = resolveMultipartContainerJobIdFromJob( + childJob, + options?.parentJobId + ); + if (!parentJobId) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(parentJobId) + ? options.parentJob + : await historyService.getJobById(parentJobId).catch(() => null); + if (!parentJob || !this.isMultipartMovieContainerHistoryJob(parentJob)) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const mediaProfile = this.resolveMediaProfileForJob(childJob, { + mediaProfile: childJob?.media_type || parentJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, childJob, normalizedChildJobId); + let targetOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + childJob, + preferredFinalOutputPath, + normalizedChildJobId, + { + containerJobId: parentJobId, + containerTitle: parentJob?.title || parentJob?.detected_title || null + } + ); + + 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)); + movePathWithFallback(sourceOutputPath, targetOutputPath); + removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); + } 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 in Incomplete-Merge-Ordner verschoben: ${sourceOutputPath} -> ${targetOutputPath}` + ); + return { + jobId: normalizedChildJobId, + outputPath: targetOutputPath, + updated: true + }; + } + + async normalizeMultipartMergeOutputsOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT + merge_job.id, + merge_job.parent_job_id, + merge_job.job_kind, + merge_job.media_type, + merge_job.output_path, + merge_job.status, + merge_job.last_state, + merge_job.encode_plan_json, + parent.job_kind AS parent_job_kind, + parent.title AS parent_title, + parent.detected_title AS parent_detected_title, + parent.media_type AS parent_media_type + FROM jobs merge_job + LEFT JOIN jobs parent ON parent.id = merge_job.parent_job_id + WHERE COALESCE(merge_job.job_kind, '') = 'multipart_movie_merge' + AND merge_job.parent_job_id IS NOT NULL + AND merge_job.output_path IS NOT NULL + AND TRIM(merge_job.output_path) <> '' + ORDER BY merge_job.id ASC + `); + const mergeRows = Array.isArray(rows) ? rows : []; + if (mergeRows.length === 0) { + return { + scanned: 0, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0 + }; + } + + let updated = 0; + let unchanged = 0; + let skipped = 0; + let failed = 0; + + for (const row of mergeRows) { + const mergeJobId = this.normalizeQueueJobId(row?.id); + const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); + const sourceOutputPath = String(row?.output_path || '').trim(); + const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); + if (!mergeJobId || !parentJobId || parentKind !== 'multipart_movie_container' || !sourceOutputPath) { + skipped += 1; + continue; + } + if (!fs.existsSync(sourceOutputPath)) { + skipped += 1; + continue; + } + + try { + const mergePlan = this.safeParseJson(row?.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + const mediaProfile = this.resolveMediaProfileForJob(row, { + encodePlan: mergePlan, + mediaProfile: row?.media_type || row?.parent_media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, row, mergeJobId); + const preferredFinalOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; + const targetOutputPath = this.isMultipartMergeJobCompletedState(row) + ? preferredFinalOutputPath + : buildIncompleteMergeOutputPathFromJob( + settings, + row, + preferredFinalOutputPath, + mergeJobId, + { + containerJobId: parentJobId, + containerTitle: row?.parent_title || row?.parent_detected_title || null + } + ); + + if ( + !targetOutputPath + || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath) + ) { + unchanged += 1; + continue; + } + + const sourceExists = fs.existsSync(sourceOutputPath); + const targetExists = fs.existsSync(targetOutputPath); + let effectiveTargetPath = targetOutputPath; + if (sourceExists && targetExists) { + effectiveTargetPath = ensureUniqueOutputPath(targetOutputPath); + } + + if (sourceExists) { + ensureDir(path.dirname(effectiveTargetPath)); + movePathWithFallback(sourceOutputPath, effectiveTargetPath); + removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); + } else if (!targetExists) { + skipped += 1; + continue; + } + + await historyService.updateJob(mergeJobId, { output_path: effectiveTargetPath }); + historyService.addJobOutputFolder(mergeJobId, effectiveTargetPath).catch(() => {}); + await historyService.appendLog( + mergeJobId, + 'SYSTEM', + `Merge-Output beim Start korrigiert: ${sourceOutputPath} -> ${effectiveTargetPath}` + ); + updated += 1; + } catch (error) { + failed += 1; + logger.warn('startup:multipart-merge-output-normalize:failed', { + mergeJobId, + parentJobId, + outputPath: sourceOutputPath, + error: errorToMeta(error) + }); + } + } + + return { + scanned: mergeRows.length, + updated, + unchanged, + skipped, + failed + }; + } + async upsertMultipartMergeJobForContainer(containerJobId, options = {}) { const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); if (!normalizedContainerJobId) { @@ -11513,6 +12125,15 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); }); + await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(normalizedChildId, { + childJob: child, + parentJob: containerJob + }).catch((error) => { + logger.warn('multipart:child-output:incomplete-merge-prefix-failed', { + childJobId: normalizedChildId, + error: errorToMeta(error) + }); + }); const refreshedChild = await historyService.getJobById(normalizedChildId).catch(() => child); if (refreshedChild) { refreshedDiscChildren.push(refreshedChild); @@ -11632,6 +12253,19 @@ class PipelineService extends EventEmitter { if (existingMergeJob) { const existingStatus = String(existingMergeJob?.status || existingMergeJob?.last_state || '').trim().toUpperCase(); const isRunning = existingStatus === 'ENCODING'; + const existingOutputPath = String(existingMergeJob?.output_path || '').trim(); + const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); + const existingSourceItems = normalizeMultipartMergeSourceItemsFromPlan(existingMergePlan); + const orderedSourceIdsChanged = existingOrderIds.length !== orderedSourceJobIds.length + || existingOrderIds.some((sourceJobId, index) => Number(sourceJobId) !== Number(orderedSourceJobIds[index])); + const mergeSourcesChanged = orderedSourceIdsChanged + || !areMultipartMergeSourceItemsEquivalent(existingSourceItems, sourceItems); + const shouldPreserveFinishedResult = ( + existingStatus === 'FINISHED' + && !mergeSourcesChanged + && existingOutputPath + && fs.existsSync(existingOutputPath) + ); await historyService.updateJob(existingMergeJob.id, { parent_job_id: normalizedContainerJobId, title: containerJob.title || containerJob.detected_title || existingMergeJob.detected_title || null, @@ -11647,7 +12281,7 @@ class PipelineService extends EventEmitter { makemkv_info_json: containerJob.makemkv_info_json || null, encode_plan_json: JSON.stringify(mergePlan), encode_review_confirmed: 1, - ...(isRunning + ...((isRunning || shouldPreserveFinishedResult) ? {} : { status: 'READY_TO_START', @@ -12213,10 +12847,15 @@ class PipelineService extends EventEmitter { mergeOutputTemplatePath, sourceItems ) || mergeOutputTemplatePath; - const incompleteOutputPath = path.join( - path.dirname(preferredFinalOutputPath), - `Incomplete_job-${normalizedJobId}`, - path.basename(preferredFinalOutputPath) + const incompleteOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + mergeJob, + preferredFinalOutputPath, + normalizedJobId, + { + containerJobId: mergeJob?.parent_job_id, + containerTitle: mergeJob?.title || mergeJob?.detected_title || null + } ); ensureDir(path.dirname(incompleteOutputPath)); @@ -17866,9 +18505,7 @@ class PipelineService extends EventEmitter { return this.runMediainfoReviewForJob(jobId, rawPath, options); } - async submitRawDecision(jobId, decision) { - const historyService = require('./historyService'); // already required at top? wait, pipelineService requires historyService. We can just use historyService directly. const job = await historyService.getJobById(jobId); if (!job) throw new Error('Job nicht gefunden.'); @@ -17880,6 +18517,418 @@ class PipelineService extends EventEmitter { throw new Error(`Job ist nicht im Status WAITING_FOR_USER_DECISION (${job.status})`); } + const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const currentAnalyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const playlistDecisionRequired = Boolean(currentAnalyzeContext.playlistDecisionRequired); + const selectedTitleId = currentAnalyzeContext.selectedTitleId ?? null; + const requiresManualPlaylistSelection = Boolean(playlistDecisionRequired && selectedTitleId === null); + + if (decision === 'multipart_orphan_merge') { + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); + if (!isSeriesDiscMediaProfile(mediaProfile)) { + const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer DVD/Blu-ray verfuegbar.'); + error.statusCode = 409; + throw error; + } + + const selectedMetadata = resolveSelectedMetadataForJob(job, currentAnalyzeContext, null); + const workflowKind = normalizeDvdMetadataWorkflowKind( + selectedMetadata?.workflowKind + || currentAnalyzeContext?.workflowKind + || null + ); + if (workflowKind !== 'film') { + const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer Film-Metadaten erlaubt.'); + error.statusCode = 409; + throw error; + } + + const rawDecisionContext = currentAnalyzeContext?.rawDecisionContext && typeof currentAnalyzeContext.rawDecisionContext === 'object' + ? currentAnalyzeContext.rawDecisionContext + : {}; + if (!rawDecisionContext?.mergeOrphanEligible) { + const error = new Error('Dieser Job hat kein geeignetes Orphan-RAW fuer Multipart movie.'); + error.statusCode = 409; + throw error; + } + + const rawPathCandidate = normalizeComparablePath( + rawDecisionContext?.matchedRawPathAfterMetadata + || rawDecisionContext?.renamedRawPath + || job.raw_path + || null + ); + if (!rawPathCandidate) { + const error = new Error('Kein gueltiger RAW-Pfad fuer den Multipart-Import gefunden.'); + error.statusCode = 409; + throw error; + } + if (!fs.existsSync(rawPathCandidate) || !fs.statSync(rawPathCandidate).isDirectory()) { + const error = new Error(`RAW-Pfad fuer Multipart-Import nicht gefunden: ${rawPathCandidate}`); + error.statusCode = 404; + throw error; + } + + const pickNextAvailableDiscNumber = (takenValues = new Set(), start = 1) => { + let next = normalizePositiveInteger(start) || 1; + while (takenValues.has(next)) { + next += 1; + } + return next; + }; + + const filmMetadataFingerprint = String(job?.metadata_fingerprint || '').trim() + || buildMovieMetadataFingerprint(mediaProfile, { + imdbId: selectedMetadata?.imdbId || job?.imdb_id || null, + title: selectedMetadata?.title || job?.title || job?.detected_title || null, + year: selectedMetadata?.year || job?.year || null + }) + || null; + + let multipartContainerJob = null; + const parentJobId = normalizePositiveInteger(job?.parent_job_id); + if (parentJobId) { + const candidateParent = await historyService.getJobById(parentJobId).catch(() => null); + if (candidateParent && this.isMultipartMovieContainerHistoryJob(candidateParent)) { + multipartContainerJob = candidateParent; + } + } + if (!multipartContainerJob && filmMetadataFingerprint) { + const db = await getDb(); + const containerRow = await db.get( + ` + SELECT id + FROM jobs + WHERE job_kind = 'multipart_movie_container' + AND media_type = ? + AND metadata_fingerprint = ? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `, + [mediaProfile, filmMetadataFingerprint] + ); + if (containerRow?.id) { + multipartContainerJob = await historyService.getJobById(containerRow.id).catch(() => null); + } + } + if (!multipartContainerJob) { + multipartContainerJob = await historyService.createJob({ + discDevice: job?.disc_device || null, + status: 'READY_TO_START', + detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, + mediaType: mediaProfile, + jobKind: 'multipart_movie_container' + }); + } + + const multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id); + if (!multipartContainerJobId) { + const error = new Error('Multipart-Container konnte nicht angelegt werden.'); + error.statusCode = 500; + throw error; + } + + const existingContainerChildren = await historyService.listChildJobs(multipartContainerJobId); + const takenDiscNumbers = new Set(); + for (const child of Array.isArray(existingContainerChildren) ? existingContainerChildren : []) { + const childId = normalizePositiveInteger(child?.id); + if (!childId || childId === Number(jobId)) { + continue; + } + const childDisc = resolveDiscNumberFromJobLike(child); + if (childDisc) { + takenDiscNumbers.add(childDisc); + } + } + + const suggestedPair = resolveMultipartDiscNumberPair({ + preferredCurrentDiscNumber: normalizePositiveInteger( + rawDecisionContext?.multipartSuggestion?.currentDiscNumber + ?? selectedMetadata?.discNumber + ?? job?.disc_number + ?? null + ), + preferredOrphanDiscNumber: normalizePositiveInteger( + rawDecisionContext?.multipartSuggestion?.orphanDiscNumber + ?? extractMultipartFilmDiscFromRawFolderName(path.basename(rawPathCandidate)) + ?? null + ) + }); + let orphanDiscNumber = suggestedPair.orphanDiscNumber; + if (takenDiscNumbers.has(orphanDiscNumber)) { + orphanDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, orphanDiscNumber); + } + takenDiscNumbers.add(orphanDiscNumber); + + let currentDiscNumber = suggestedPair.currentDiscNumber; + if (!currentDiscNumber || takenDiscNumbers.has(currentDiscNumber)) { + currentDiscNumber = pickNextAvailableDiscNumber( + takenDiscNumbers, + currentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1) + ); + } + if (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, currentDiscNumber + 1); + } + takenDiscNumbers.add(currentDiscNumber); + + const orphanJob = await historyService.createJob({ + discDevice: null, + status: 'READY_TO_START', + detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, + mediaType: mediaProfile, + jobKind: 'multipart_movie_child' + }); + const orphanJobId = normalizePositiveInteger(orphanJob?.id); + if (!orphanJobId) { + const error = new Error('Orphan-Child-Job konnte nicht erstellt werden.'); + error.statusCode = 500; + throw error; + } + + const currentMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( + selectedMetadata, + currentDiscNumber + ); + const orphanMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( + selectedMetadata, + orphanDiscNumber + ); + const containerSelectedMetadata = normalizeMultipartMovieSelectedMetadata(selectedMetadata, null); + + const orphanAnalyzeContext = { + ...currentAnalyzeContext, + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + metadataProvider: 'omdb', + workflowKind: 'film', + selectedMetadata: orphanMultipartSelectedMetadata, + rawDecisionContext: null + }; + const currentAnalyzeContextPatch = { + ...currentAnalyzeContext, + metadataProvider: 'omdb', + workflowKind: 'film', + selectedMetadata: currentMultipartSelectedMetadata, + rawDecisionContext: null + }; + + const sourceRawState = resolveRawFolderStateFromPath(rawPathCandidate); + const orphanMetadataBase = buildRawMetadataBase({ + title: selectedMetadata?.title || job?.title || job?.detected_title || null, + year: selectedMetadata?.year || job?.year || null, + detected_title: job?.detected_title || null, + media_type: mediaProfile, + is_multipart_movie: 1, + job_kind: 'multipart_movie_child' + }, orphanJobId, { + mediaProfile, + analyzeContext: orphanAnalyzeContext, + selectedMetadata: orphanMultipartSelectedMetadata, + isMultipartMovie: true + }); + const orphanTargetRawPath = path.join( + path.dirname(rawPathCandidate), + buildRawDirName(orphanMetadataBase, orphanJobId, { state: sourceRawState }) + ); + let finalOrphanRawPath = rawPathCandidate; + if (normalizeComparablePath(orphanTargetRawPath) !== normalizeComparablePath(rawPathCandidate)) { + if (fs.existsSync(orphanTargetRawPath)) { + const error = new Error(`RAW-Zielpfad fuer Orphan-Child existiert bereits: ${orphanTargetRawPath}`); + error.statusCode = 409; + throw error; + } + fs.renameSync(rawPathCandidate, orphanTargetRawPath); + await historyService.updateRawPathByOldPath(rawPathCandidate, orphanTargetRawPath); + finalOrphanRawPath = orphanTargetRawPath; + } + + const topLevelSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + const orphanMakemkvInfo = this.withAnalyzeContextMediaProfile({ + ...mkInfo, + source: 'orphan_raw_import', + rawPath: finalOrphanRawPath, + analyzeContext: orphanAnalyzeContext, + selectedMetadata: { + ...topLevelSelectedMetadata, + ...orphanMultipartSelectedMetadata + } + }, mediaProfile); + const currentMakemkvInfo = this.withAnalyzeContextMediaProfile({ + ...mkInfo, + analyzeContext: currentAnalyzeContextPatch, + selectedMetadata: { + ...topLevelSelectedMetadata, + ...currentMultipartSelectedMetadata + } + }, mediaProfile); + const containerMakemkvInfo = this.withAnalyzeContextMediaProfile({ + analyzeContext: { + metadataProvider: 'omdb', + workflowKind: 'film', + selectedMetadata: containerSelectedMetadata + }, + selectedMetadata: containerSelectedMetadata + }, mediaProfile); + + const commonTitle = selectedMetadata?.title || job?.title || job?.detected_title || null; + const commonYear = selectedMetadata?.year || job?.year || null; + const commonImdbId = selectedMetadata?.imdbId || job?.imdb_id || null; + const commonPoster = selectedMetadata?.poster || job?.poster_url || null; + const selectedFromOmdb = Number(job?.selected_from_omdb || 0) || 0; + const omdbJsonValue = job?.omdb_json || null; + + await historyService.updateJob(multipartContainerJobId, { + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: mediaProfile, + parent_job_id: null, + job_kind: 'multipart_movie_container', + is_multipart_movie: 1, + disc_number: null, + metadata_fingerprint: filmMetadataFingerprint, + makemkv_info_json: JSON.stringify(containerMakemkvInfo) + }); + + await historyService.updateJob(orphanJobId, { + parent_job_id: multipartContainerJobId, + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: mediaProfile, + job_kind: 'multipart_movie_child', + is_multipart_movie: 1, + disc_number: orphanDiscNumber, + metadata_fingerprint: filmMetadataFingerprint, + raw_path: finalOrphanRawPath, + output_path: null, + rip_successful: 1, + makemkv_info_json: JSON.stringify(orphanMakemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + await historyService.updateJob(jobId, { + parent_job_id: multipartContainerJobId, + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + status: nextStatus, + last_state: nextStatus, + media_type: mediaProfile, + job_kind: 'multipart_movie_child', + is_multipart_movie: 1, + disc_number: currentDiscNumber, + metadata_fingerprint: filmMetadataFingerprint, + raw_path: null, + rip_successful: 0, + makemkv_info_json: JSON.stringify(currentMakemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + await historyService.appendLog( + orphanJobId, + 'SYSTEM', + `Orphan-RAW in Multipart-Container uebernommen: Container #${multipartContainerJobId}, Disc ${orphanDiscNumber}, RAW=${finalOrphanRawPath}` + ); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart movie vorbereitet: Container #${multipartContainerJobId}, Laufwerk=Disc ${currentDiscNumber}, Orphan=Disc ${orphanDiscNumber} (Job #${orphanJobId}).` + ); + await historyService.appendLog( + multipartContainerJobId, + 'SYSTEM', + `Multipart movie aus RAW-Entscheidung erstellt: Disc ${currentDiscNumber} (Job #${jobId}) + Disc ${orphanDiscNumber} (Job #${orphanJobId}).` + ); + + await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-raw-decision-failed', { + containerJobId: multipartContainerJobId, + jobId, + orphanJobId, + error: errorToMeta(mergePrepError) + }); + }); + + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : 'Multipart vorbereitet - Laufwerks-Rip startet', + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath: null, + mediaProfile, + selectedMetadata: currentMultipartSelectedMetadata, + waitingForRawDecision: false, + waitingForManualPlaylistSelection: requiresManualPlaylistSelection, + manualDecisionState: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null, + rawDecisionOptions: null, + multipartContext: { + containerJobId: multipartContainerJobId, + orphanJobId, + currentDiscNumber, + orphanDiscNumber + } + } + }); + + if (requiresManualPlaylistSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Multipart movie vorbereitet. Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor der Laufwerks-Rip gestartet wird.' + ); + return historyService.getJobById(jobId); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Starte Backup/Rip fuer Laufwerk-Disc ${currentDiscNumber}. Orphan-RAW bleibt als Disc ${orphanDiscNumber} (Job #${orphanJobId}) im Container.` + ); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + if (decision === 'delete') { const fsOptions = { recursive: true, force: true }; if (job.raw_path && require('fs').existsSync(job.raw_path)) { @@ -17895,12 +18944,6 @@ class PipelineService extends EventEmitter { } else { throw new Error(`Unbekannte Entscheidung: ${decision}`); } - - const mkInfo = this.safeParseJson(job.makemkv_info_json); - const currentAnalyzeContext = mkInfo?.analyzeContext || {}; - const playlistDecisionRequired = Boolean(currentAnalyzeContext.playlistDecisionRequired); - const selectedTitleId = currentAnalyzeContext.selectedTitleId ?? null; - const requiresManualPlaylistSelection = Boolean(playlistDecisionRequired && selectedTitleId === null); const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; @@ -17916,6 +18959,7 @@ class PipelineService extends EventEmitter { context: { ...(this.snapshot.context || {}), waitingForRawDecision: false, + rawDecisionOptions: null, manualDecisionState: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : null @@ -19099,13 +20143,34 @@ class PipelineService extends EventEmitter { if (isMultipartMovieSelection && multipartExistingJobId && multipartExistingSelectedMetadataForRawRename) { try { const existingJobForRawRename = await historyService.getJobById(multipartExistingJobId).catch(() => null); + const existingJobHasActiveProcess = this._isActiveProcessForJob( + multipartExistingJobId, + { cleanupStale: true } + ); + if (existingJobHasActiveProcess) { + await historyService.appendLog( + multipartExistingJobId, + 'SYSTEM', + 'Multipart-RAW-Rename verschoben: Job ist aktiv, Pfad bleibt waehrend Analyze/Encode stabil.' + ); + logger.info('metadata:multipart-existing-raw-rename-deferred-active-job', { + jobId: multipartExistingJobId, + status: String(existingJobForRawRename?.status || '').trim().toUpperCase() || null, + lastState: String(existingJobForRawRename?.last_state || '').trim().toUpperCase() || 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()) { + if ( + !existingJobHasActiveProcess + && resolvedExistingRawPath + && fs.existsSync(resolvedExistingRawPath) + && fs.statSync(resolvedExistingRawPath).isDirectory() + ) { const existingRawState = resolveRawFolderStateFromPath(resolvedExistingRawPath); const targetRawState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE ? RAW_FOLDER_STATES.INCOMPLETE @@ -19195,6 +20260,48 @@ class PipelineService extends EventEmitter { resolvedCurrentJobRawPath && fs.existsSync(resolvedCurrentJobRawPath) ); + const normalizedMetadataMatchedRawPath = normalizeComparablePath(metadataMatchedRawPath); + const normalizedCurrentJobRawPath = normalizeComparablePath(resolvedCurrentJobRawPath); + const metadataMatchedIsCurrentJobRaw = Boolean( + normalizedMetadataMatchedRawPath + && normalizedCurrentJobRawPath + && normalizedMetadataMatchedRawPath === normalizedCurrentJobRawPath + ); + let metadataMatchedLinkedJobId = null; + if (normalizedMetadataMatchedRawPath) { + const dbForRawDecision = await getDb(); + const linkedRawRows = await dbForRawDecision.all( + ` + SELECT id, raw_path + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ); + const linkedRow = (Array.isArray(linkedRawRows) ? linkedRawRows : []).find((row) => { + const rowId = normalizePositiveInteger(row?.id); + if (!rowId || rowId === Number(jobId)) { + return false; + } + return normalizeComparablePath(row?.raw_path) === normalizedMetadataMatchedRawPath; + }); + metadataMatchedLinkedJobId = normalizePositiveInteger(linkedRow?.id); + } + const metadataMatchedLooksOrphan = Boolean( + normalizedMetadataMatchedRawPath + && !metadataMatchedIsCurrentJobRaw + && !metadataMatchedLinkedJobId + ); + const orphanDiscHint = metadataMatchedRawPath + ? extractMultipartFilmDiscFromRawFolderName(path.basename(metadataMatchedRawPath)) + : null; + const multipartDiscSuggestions = resolveMultipartDiscNumberPair({ + preferredCurrentDiscNumber: normalizePositiveInteger( + selectedMetadata?.discNumber + ?? effectiveDiscNumber + ?? null + ), + preferredOrphanDiscNumber: orphanDiscHint + }); let updatedRawPath = existingRawPath || (currentJobRawPathPresent ? resolvedCurrentJobRawPath : null); const shouldAutoReviewFromRaw = Boolean(existingRawPath || currentJobRawPathExists); if (existingRawPath) { @@ -19232,6 +20339,26 @@ class PipelineService extends EventEmitter { playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob && !isMultipartMovieSelection; + const canMergeWithMatchedOrphanRaw = Boolean( + requiresRawDecision + && isDiscFilmMetadataSelection + && !isMultipartMovieSelection + && metadataMatchedLooksOrphan + ); + const rawDecisionContext = { + matchedRawPathBeforeMetadata: existingRawPath || null, + matchedRawPathAfterMetadata: updatedRawPath || null, + matchedRawFolderJobId: metadataMatchedRawJobId || null, + matchedRawLinkedJobId: metadataMatchedLinkedJobId || null, + matchedRawLooksOrphan: metadataMatchedLooksOrphan, + mergeOrphanEligible: canMergeWithMatchedOrphanRaw, + multipartSuggestion: canMergeWithMatchedOrphanRaw + ? { + orphanDiscNumber: multipartDiscSuggestions.orphanDiscNumber, + currentDiscNumber: multipartDiscSuggestions.currentDiscNumber + } + : null + }; const nextStatus = (requiresManualPlaylistSelection || requiresRawDecision) ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ @@ -19244,7 +20371,8 @@ class PipelineService extends EventEmitter { selectedTitleId: playlistDecision.selectedTitleId ?? null, metadataProvider: effectiveMetadataProvider, ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), - selectedMetadata + selectedMetadata, + rawDecisionContext: requiresRawDecision ? rawDecisionContext : null } }, mediaProfile); @@ -19392,6 +20520,13 @@ class PipelineService extends EventEmitter { selectedTitleId: playlistDecision.selectedTitleId ?? null, waitingForManualPlaylistSelection: requiresManualPlaylistSelection, waitingForRawDecision: requiresRawDecision, + rawDecisionOptions: requiresRawDecision + ? { + allowMultipartMergeWithOrphan: Boolean(rawDecisionContext?.mergeOrphanEligible), + orphanDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber), + currentDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber) + } + : null, manualDecisionState: requiresRawDecision ? 'waiting_for_raw_decision' : (requiresManualPlaylistSelection @@ -19414,6 +20549,15 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Vorhandenes RAW zur Disk gefunden: ${updatedRawPath}. Warte auf Entscheidung.` ); + if (rawDecisionContext?.mergeOrphanEligible) { + const orphanDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber); + const currentDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Optional verfuegbar: Multipart movie aus Orphan-RAW erstellen (Orphan Disc ${orphanDiscSuggestion || '?'} + Laufwerk Disc ${currentDiscSuggestion || '?'}).` + ); + } return historyService.getJobById(jobId); } @@ -23062,7 +24206,19 @@ class PipelineService extends EventEmitter { } : job; const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, outputPathJobView, jobId); - const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); + let preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); + if (this.isMultipartMovieDiscChildHistoryJob(outputPathJobView)) { + const containerJobId = resolveMultipartContainerJobIdFromJob(outputPathJobView); + preferredFinalOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + outputPathJobView, + preferredFinalOutputPath, + jobId, + { + containerJobId + } + ); + } ensureDir(path.dirname(incompleteOutputPath)); const liveJobContext = this.jobProgress.get(Number(jobId))?.context; const existingSeriesBatchContext = isSeriesBatchEpisodeRun @@ -24686,6 +25842,14 @@ class PipelineService extends EventEmitter { }); this.cancelRequestedByJob.delete(Number(jobId)); const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); + const AUTO_RECOVERY_TRIGGER_REASONS = new Set([ + 'failed_encode', + 'cancelled_encode', + 'server_restart', + 'confirm_auto_prepare' + ]); + const isAutoRecoveryTrigger = AUTO_RECOVERY_TRIGGER_REASONS.has(triggerReason); + const preserveJobIdOnAutoRecovery = isAutoRecoveryTrigger; const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); @@ -24741,6 +25905,7 @@ class PipelineService extends EventEmitter { const restartDeleteIncompleteOutput = settings?.handbrake_restart_delete_incomplete_output !== undefined ? Boolean(settings.handbrake_restart_delete_incomplete_output) : true; + const effectiveRestartDeleteIncompleteOutput = restartDeleteIncompleteOutput && !isAutoRecoveryTrigger; const handBrakeInfo = this.safeParseJson(job.handbrake_info_json); const previousOutputPath = String(job.output_path || '').trim() || null; @@ -24762,7 +25927,7 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); } - } else if (previousOutputPath && restartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) { + } else if (previousOutputPath && effectiveRestartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) { try { const deleteResult = await historyService.deleteJobFiles(jobId, 'movie'); await historyService.appendLog( @@ -24864,7 +26029,7 @@ class PipelineService extends EventEmitter { if ( effectiveRestartMode === 'from_abort' && restartFromEntry - && restartDeleteIncompleteOutput + && effectiveRestartDeleteIncompleteOutput && !keepBoth ) { const restartChildOutputPath = String(restartFromEntry?.outputPath || '').trim(); @@ -24954,32 +26119,36 @@ class PipelineService extends EventEmitter { ? Boolean(restartPlan?.encodeInputTitleId) : Boolean(inputPath); - const replacementJob = await historyService.createJob({ - discDevice: job.disc_device || null, - status: 'READY_TO_ENCODE', - detectedTitle: job.detected_title || job.title || null, - jobKind: this.resolveJobKindForJob(job, { - encodePlan: restartPlan - }) - }); - const replacementJobId = Number(replacementJob?.id || 0); - if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { - throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + let replacementJobId = Number(jobId); + let replacedSourceJob = false; + if (!preserveJobIdOnAutoRecovery) { + const replacementJob = await historyService.createJob({ + discDevice: job.disc_device || null, + status: 'READY_TO_ENCODE', + detectedTitle: job.detected_title || job.title || null, + jobKind: this.resolveJobKindForJob(job, { + encodePlan: restartPlan + }) + }); + replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + replacedSourceJob = true; + + const previousRestartRawPath = activeRestartRawPath; + activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); + if ( + previousRestartRawPath + && activeRestartRawPath + && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) + ) { + inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); + restartPlan.encodeInputPath = inputPath; + } } - const previousRestartRawPath = activeRestartRawPath; - activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); - if ( - previousRestartRawPath - && activeRestartRawPath - && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) - ) { - inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); - restartPlan.encodeInputPath = inputPath; - } - - await historyService.updateJob(replacementJobId, { - parent_job_id: Number(jobId), + const replacementJobPatch = { media_type: preservedRestartMediaType, title: job.title || null, year: job.year ?? null, @@ -25001,10 +26170,16 @@ class PipelineService extends EventEmitter { encode_plan_json: JSON.stringify(restartPlan), encode_input_path: inputPath, encode_review_confirmed: 0 - }); + }; + if (replacedSourceJob) { + replacementJobPatch.parent_job_id = Number(jobId); + } else { + replacementJobPatch.start_time = null; + } + await historyService.updateJob(replacementJobId, replacementJobPatch); // Keep local poster thumbnails valid for the replacement job id. - if (thumbnailService.isLocalUrl(job.poster_url)) { + if (replacedSourceJob && thumbnailService.isLocalUrl(job.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); if (copiedUrl) { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); @@ -25019,7 +26194,7 @@ class PipelineService extends EventEmitter { : ''; const loadedSelectionText = ( previousOutputPath - ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}.${restartModeDetails}` + ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${effectiveRestartDeleteIncompleteOutput ? 'on' : 'off'}.${restartModeDetails}` : `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.${restartModeDetails}` ); let restartLogMessage; @@ -25035,12 +26210,14 @@ class PipelineService extends EventEmitter { restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`; } await historyService.appendLog(replacementJobId, 'USER_ACTION', restartLogMessage); - if (preservedRestartMediaType && !explicitJobMediaType) { + if (replacedSourceJob && preservedRestartMediaType && !explicitJobMediaType) { await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); } - await historyService.retireJobInFavorOf(jobId, replacementJobId, { - reason: 'restart_encode' - }); + if (replacedSourceJob) { + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'restart_encode' + }); + } await this.setState('READY_TO_ENCODE', { activeJobId: replacementJobId, @@ -25077,7 +26254,7 @@ class PipelineService extends EventEmitter { reviewConfirmed: false, sourceJobId: Number(jobId), jobId: replacementJobId, - replacedSourceJob: true, + replacedSourceJob, restartMode: seriesBatchRestartSummary?.mode || effectiveRestartMode }; } @@ -28080,9 +29257,21 @@ class PipelineService extends EventEmitter { } } } else { - const newOutputPath = isAudiobook + let newOutputPath = isAudiobook ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath : buildFinalOutputPathFromJob(settings, job, jobId); + if (!isAudiobook && this.isMultipartMovieDiscChildHistoryJob(job)) { + const containerJobId = resolveMultipartContainerJobIdFromJob(job); + newOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + job, + newOutputPath, + jobId, + { + containerJobId + } + ); + } if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) { fs.mkdirSync(path.dirname(newOutputPath), { recursive: true }); moveFileWithFallback(currentOutputPath, newOutputPath); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6703761..3e81d08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.15.0-1", + "version": "0.15.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.15.0-1", + "version": "0.15.0-2", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index bae91b0..a525a10 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.15.0-1", + "version": "0.15.0-2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af3ee9f..3e43c22 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -602,15 +602,15 @@ function App() {