From 120a9e6ad1c556a5bab6878142b52ead5c23f690 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 22 Apr 2026 20:32:47 +0000 Subject: [PATCH] 0.15.0-1 merger --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/index.js | 3 + backend/src/services/historyService.js | 18 + backend/src/services/pipelineService.js | 87 +++- backend/src/services/tempCleanupService.js | 156 ++++++ backend/src/utils/progressParsers.js | 28 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/components/JobDetailDialog.jsx | 116 ++++- .../src/components/PipelineStatusCard.jsx | 5 + frontend/src/pages/HistoryPage.jsx | 30 +- frontend/src/pages/RipperPage.jsx | 449 ++++++++++++------ frontend/src/styles/app.css | 15 +- package-lock.json | 4 +- package.json | 2 +- 16 files changed, 728 insertions(+), 197 deletions(-) create mode 100644 backend/src/services/tempCleanupService.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 074813a..a7fa113 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.15.0", + "version": "0.15.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.15.0", + "version": "0.15.0-1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 4de08c2..89714ef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.15.0", + "version": "0.15.0-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/index.js b/backend/src/index.js index 9f66233..99a6161 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -22,6 +22,7 @@ const downloadService = require('./services/downloadService'); const diskDetectionService = require('./services/diskDetectionService'); const hardwareMonitorService = require('./services/hardwareMonitorService'); const coverArtRecoveryService = require('./services/coverArtRecoveryService'); +const tempCleanupService = require('./services/tempCleanupService'); const logger = require('./services/logger').child('BOOT'); const { errorToMeta } = require('./utils/errorMeta'); const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); @@ -29,6 +30,7 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum async function start() { logger.info('backend:start:init'); await initDatabase(); + await tempCleanupService.init(); await pipelineService.init(); await converterScanService.startPolling(); await cronService.init(); @@ -95,6 +97,7 @@ async function start() { coverArtRecoveryService.stop(); hardwareMonitorService.stop(); cronService.stop(); + tempCleanupService.stop(); server.close(() => { logger.warn('backend:shutdown:completed'); process.exit(0); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 948cfdd..71b1d39 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -922,6 +922,23 @@ function enrichJobRow(job, settings = null, options = {}) { const makemkvInfo = resolvedPaths.makemkvInfo; const mediainfoInfo = resolvedPaths.mediainfoInfo; const encodePlan = resolvedPaths.encodePlan; + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}); + const resolvedPosterUrl = String( + job?.poster_url + || selectedMetadata?.poster + || selectedMetadata?.coverUrl + || selectedMetadata?.posterUrl + || encodePlan?.metadata?.poster + || encodePlan?.metadata?.coverUrl + || '' + ).trim() || null; const mediaType = resolvedPaths.mediaType; const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); const ripSuccessful = Number(job?.rip_successful || 0) === 1 @@ -953,6 +970,7 @@ function enrichJobRow(job, settings = null, options = {}) { return { ...job, + poster_url: resolvedPosterUrl, raw_path: resolvedPaths.effectiveRawPath, output_path: resolvedPaths.effectiveOutputPath, makemkvInfo, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index a410163..99bbbce 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -21,7 +21,7 @@ const diskDetectionService = require('./diskDetectionService'); const notificationService = require('./notificationService'); const logger = require('./logger').child('PIPELINE'); const { spawnTrackedProcess } = require('./processRunner'); -const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); +const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers'); const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); const { buildMediainfoReview } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); @@ -2077,6 +2077,25 @@ function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`); } +function buildMultipartMergeOutputPath(templateOutputPath, sourceItems = []) { + const normalizedTemplateOutputPath = String(templateOutputPath || '').trim(); + if (!normalizedTemplateOutputPath) { + return null; + } + + 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 preferredExt = String(templateParsed.ext || '').trim() || '.mkv'; + const preferredName = String(templateParsed.name || '').trim() || 'merged_output'; + return path.join(preferredDir, `${preferredName}${preferredExt}`); +} + function ensureUniqueOutputPath(outputPath) { if (!fs.existsSync(outputPath)) { return outputPath; @@ -2383,9 +2402,11 @@ function extractProgressDetail(source, line) { } function composeStatusText(stage, percent, detail) { + const normalizedStage = String(stage || '').trim().toUpperCase(); + const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage; const base = percent !== null && percent !== undefined - ? `${stage} ${percent.toFixed(2)}%` - : stage; + ? `${baseLabel} ${percent.toFixed(2)}%` + : baseLabel; if (detail) { return `${base} - ${detail}`; @@ -11503,8 +11524,10 @@ class PipelineService extends EventEmitter { 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 templateOutputPath = withPathBaseNameSuffix( + buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId), + '_merged' + ); const existingMergeJob = mergeJobs[0] || null; const createIfMissing = options?.createIfMissing !== false; const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {}; @@ -11560,6 +11583,8 @@ class PipelineService extends EventEmitter { sourceCount: sourceItems.length }; } + const mergeOutputPath = buildMultipartMergeOutputPath(templateOutputPath, sourceItems) + || templateOutputPath; const mergePlan = { mode: 'multipart_merge', @@ -11933,8 +11958,12 @@ class PipelineService extends EventEmitter { 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() + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + const expectedOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content); @@ -12022,9 +12051,19 @@ class PipelineService extends EventEmitter { 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() + const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; + await this.ensureExternalToolAvailable(mkvmergeCommand, { label: 'mkvmerge' }); + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + const preferredFinalOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; + const incompleteOutputPath = path.join( + path.dirname(preferredFinalOutputPath), + `Incomplete_job-${normalizedJobId}`, + path.basename(preferredFinalOutputPath) + ); ensureDir(path.dirname(incompleteOutputPath)); await this.setState('ENCODING', { @@ -12053,7 +12092,6 @@ class PipelineService extends EventEmitter { `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; @@ -12113,7 +12151,8 @@ class PipelineService extends EventEmitter { stage: 'ENCODING', source: 'MKVMERGE', cmd: mkvmergeCommand, - args: mkvmergeArgs + args: mkvmergeArgs, + parser: parseMkvmergeProgress }); const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, @@ -14783,6 +14822,33 @@ class PipelineService extends EventEmitter { }); } + async ensureExternalToolAvailable(cmd, options = {}) { + const command = String(cmd || '').trim(); + const label = String(options?.label || command || 'Tool').trim() || 'Tool'; + const versionArgs = Array.isArray(options?.versionArgs) && options.versionArgs.length > 0 + ? options.versionArgs.map((item) => String(item)) + : ['--version']; + if (!command) { + const error = new Error(`${label} ist nicht konfiguriert.`); + error.statusCode = 500; + throw error; + } + + try { + await this.runCapturedCommand(command, versionArgs); + } catch (error) { + if (String(error?.code || '').trim().toUpperCase() === 'ENOENT') { + const resolvedError = new Error( + `${label} konnte nicht gestartet werden. Bitte das Kommando in den Settings prüfen oder ${label} auf dem Server installieren.` + ); + resolvedError.statusCode = 500; + resolvedError.code = 'ENOENT'; + throw resolvedError; + } + throw error; + } + } + async ensureMakeMKVRegistration(jobId, stage) { const registrationConfig = await settingsService.buildMakeMKVRegisterConfig(); if (!registrationConfig) { @@ -23944,6 +24010,7 @@ class PipelineService extends EventEmitter { ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null ); const isCdRetry = mediaProfile === 'cd'; + const isAudiobookRetry = mediaProfile === 'audiobook'; // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) diff --git a/backend/src/services/tempCleanupService.js b/backend/src/services/tempCleanupService.js new file mode 100644 index 0000000..ebfc8a0 --- /dev/null +++ b/backend/src/services/tempCleanupService.js @@ -0,0 +1,156 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const logger = require('./logger').child('TEMP_CLEANUP'); + +const TMP_ROOT = os.tmpdir(); +const SWEEP_INTERVAL_MS = 60 * 60 * 1000; +const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000; + +const TOP_LEVEL_PREFIXES = [ + 'ripster-merge-', + 'ripster-script-', + 'ripster-export-' +]; + +const MANAGED_UPLOAD_DIRS = [ + 'ripster-converter-uploads', + 'ripster-audiobook-uploads' +]; + +function getEntryAgeMs(stats) { + const referenceTime = Math.max( + Number(stats?.mtimeMs) || 0, + Number(stats?.ctimeMs) || 0, + Number(stats?.birthtimeMs) || 0 + ); + return Date.now() - referenceTime; +} + +function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) { + return getEntryAgeMs(stats) >= minAgeMs; +} + +function removeEntry(targetPath, stats) { + const isDirectory = stats?.isDirectory?.() || false; + fs.rmSync(targetPath, { + recursive: isDirectory, + force: true + }); +} + +class TempCleanupService { + constructor() { + this.interval = null; + } + + async init() { + await this.runSweep('startup'); + + if (!this.interval) { + this.interval = setInterval(() => { + this.runSweep('interval').catch((error) => { + logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) }); + }); + }, SWEEP_INTERVAL_MS); + this.interval.unref?.(); + } + } + + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + async runSweep(reason = 'manual') { + const deleted = []; + const skipped = []; + const failed = []; + + let topLevelEntries = []; + try { + topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true }); + } catch (error) { + logger.warn('temp:sweep:readdir-failed', { + reason, + tmpRoot: TMP_ROOT, + error: error?.message || String(error) + }); + return { deleted, skipped, failed }; + } + + for (const entry of topLevelEntries) { + const entryName = String(entry?.name || '').trim(); + if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) { + continue; + } + const targetPath = path.join(TMP_ROOT, entryName); + try { + const stats = fs.lstatSync(targetPath); + if (!isStale(stats)) { + skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' }); + continue; + } + removeEntry(targetPath, stats); + deleted.push(targetPath); + } catch (error) { + failed.push({ + path: targetPath, + error: error?.message || String(error) + }); + } + } + + for (const dirName of MANAGED_UPLOAD_DIRS) { + const uploadDir = path.join(TMP_ROOT, dirName); + if (!fs.existsSync(uploadDir)) { + continue; + } + + let uploadEntries = []; + try { + uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true }); + } catch (error) { + failed.push({ + path: uploadDir, + error: error?.message || String(error) + }); + continue; + } + + for (const entry of uploadEntries) { + const targetPath = path.join(uploadDir, entry.name); + try { + const stats = fs.lstatSync(targetPath); + if (!isStale(stats)) { + skipped.push({ path: targetPath, reason: 'fresh-upload-entry' }); + continue; + } + removeEntry(targetPath, stats); + deleted.push(targetPath); + } catch (error) { + failed.push({ + path: targetPath, + error: error?.message || String(error) + }); + } + } + } + + logger.info('temp:sweep:completed', { + reason, + tmpRoot: TMP_ROOT, + deletedCount: deleted.length, + skippedCount: skipped.length, + failedCount: failed.length, + deleted: deleted.slice(0, 50), + failed: failed.slice(0, 20) + }); + + return { deleted, skipped, failed }; + } +} + +module.exports = new TempCleanupService(); diff --git a/backend/src/utils/progressParsers.js b/backend/src/utils/progressParsers.js index 3766250..ccea89e 100644 --- a/backend/src/utils/progressParsers.js +++ b/backend/src/utils/progressParsers.js @@ -93,8 +93,34 @@ function parseCdParanoiaProgress(line) { return null; } +function parseMkvmergeProgress(line) { + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + // Common mkvmerge output examples: + // "Progress: 42%" + // "#GUI#progress 42%" + const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i); + if (explicitMatch) { + return { + percent: clampPercent(Number(explicitMatch[1])), + eta: null + }; + } + + const percent = parseGenericPercent(normalized); + if (percent !== null) { + return { percent, eta: null }; + } + + return null; +} + module.exports = { parseMakeMkvProgress, parseHandBrakeProgress, - parseCdParanoiaProgress + parseCdParanoiaProgress, + parseMkvmergeProgress }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 330d000..6703761 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.15.0", + "version": "0.15.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.15.0", + "version": "0.15.0-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 1bbc35f..bae91b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.15.0", + "version": "0.15.0-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index c645292..77560cc 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -549,9 +549,10 @@ function resolveAudiobookDetails(job) { }; } -function statusBadgeMeta(status, queued = false) { +function statusBadgeMeta(status, queued = false, options = {}) { const normalized = String(status || '').trim().toUpperCase(); - const label = getStatusLabel(normalized, { queued }); + const labelOverride = String(options?.label || '').trim(); + const label = labelOverride || getStatusLabel(normalized, { queued }); if (queued) { return { label, icon: 'pi-list', tone: 'info' }; } @@ -585,9 +586,42 @@ function statusBadgeMeta(status, queued = false) { if (normalized === 'ENCODING') { return { label, icon: 'pi-cog', tone: 'warning' }; } + if (normalized === 'MERGING') { + return { label, icon: 'pi-spinner pi-spin', tone: 'warning' }; + } return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' }; } +function normalizePathForCompare(value) { + return String(value || '').trim().replace(/[\\/]+$/, ''); +} + +function buildMergeToolLogFallback(job = null) { + const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' + ? job.handbrakeInfo + : {}; + const lines = []; + const highlights = Array.isArray(handbrakeInfo?.highlights) ? handbrakeInfo.highlights : []; + for (const entry of highlights) { + const text = String(entry || '').trim(); + if (text) { + lines.push(text); + } + } + const stdoutTail = String(handbrakeInfo?.stdoutTail || '').trim(); + if (stdoutTail) { + lines.push(stdoutTail); + } + const stderrTail = String(handbrakeInfo?.stderrTail || '').trim(); + if (stderrTail) { + lines.push(stderrTail); + } + if (lines.length === 0) { + return ''; + } + return lines.join('\n'); +} + function omdbField(value) { const raw = String(value || '').trim(); return raw || '-'; @@ -1185,16 +1219,20 @@ export default function JobDetailDialog({ ? discIndicatorIcon : otherIndicatorIcon; const mediaTypeAlt = mediaTypeLabel; - const statusMeta = statusBadgeMeta(job?.status, queueLocked); + const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase(); + const isMultipartMergeJob = isMultipartMergeChildJob(job); + const statusMeta = statusBadgeMeta( + isMultipartMergeJob && statusUpper === 'ENCODING' ? 'MERGING' : job?.status, + queueLocked, + { label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' } + ); const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {}; const metadataContext = resolveJobMetadataContext(job); const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; - const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase(); const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container'; const isMultipartContainer = jobKindRaw === 'multipart_movie_container'; - const isMultipartMergeJob = isMultipartMergeChildJob(job); const isDiskContainer = isSeriesContainer || isMultipartContainer; const allChildJobs = Array.isArray(job?.children) ? [...job.children].sort(compareSeriesChildJobsByDisc) @@ -1332,11 +1370,28 @@ export default function JobDetailDialog({ (title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId) ); const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo); + const posterUrl = String( + job?.poster_url + || metadataContext?.selectedMetadata?.poster + || metadataContext?.selectedMetadata?.coverUrl + || metadataContext?.selectedMetadata?.posterUrl + || job?.encodePlan?.metadata?.poster + || '' + ).trim() || null; const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null; const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function'); const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function'); const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : []; + const mergeOutputPathCandidates = new Set( + [ + ...(isMultipartMergeJob ? [job?.output_path] : []), + ...(isMultipartContainer ? mergeChildJobs.map((child) => child?.output_path) : []) + ] + .map((value) => normalizePathForCompare(value)) + .filter(Boolean) + ); const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists); + const mergeToolLogFallback = isMultipartMergeJob ? buildMergeToolLogFallback(job) : ''; const canShowGeneralEncodeActions = canResumeReady || typeof onRestartEncode === 'function' || typeof onRestartReview === 'function' @@ -1407,8 +1462,8 @@ export default function JobDetailDialog({ {detailLoading ?

Details werden geladen ...

: null}
- {job.poster_url && job.poster_url !== 'N/A' ? ( - {job.title + {posterUrl && posterUrl !== 'N/A' ? ( + {job.title ) : (
{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}
)} @@ -1712,7 +1767,7 @@ export default function JobDetailDialog({
Start: {job.start_time || '-'}
Ende: {job.end_time || '-'}
{/* Zeile 3+4: Pfade */} - {!isDiskContainer ? ( + {!isDiskContainer && !isMultipartMergeJob ? ( 1 ? `Output ${idx + 1}:` : 'Output:'; + const normalizedFolderPath = normalizePathForCompare(folderPath); + const isMergeOutput = Boolean( + (normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath)) + || /_merged\.[^/.]+$/i.test(folderPath) + ); + const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output'; + const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`; const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function'); return ( onDownloadArchive?.(job, 'output') : null} downloadDisabled={!canDownloadOutput} @@ -2414,6 +2475,23 @@ export default function JobDetailDialog({ {`Merge${child?.id ? ` #${child.id}` : ''}`}

Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.

+ {typeof onDeleteEntry === 'function' ? ( +
+
+
+
+ ) : null} )) ) : ( @@ -2523,15 +2601,17 @@ export default function JobDetailDialog({ {`Merge${child?.id ? ` #${child.id}` : ''}`} -
{child.log || ''}
+
{child.log || buildMergeToolLogFallback(child) || ''}
))}
) : ( -
{job.log || ''}
+
{job.log || (isMultipartMergeJob ? mergeToolLogFallback : '') || ''}
) ) : ( -

Log nicht vorgeladen. Über die Buttons oben laden.

+ isMultipartMergeJob && mergeToolLogFallback + ?
{mergeToolLogFallback}
+ :

Log nicht vorgeladen. Über die Buttons oben laden.

)} ) : ( @@ -2764,14 +2844,18 @@ export default function JobDetailDialog({ {!queueLocked ? (
- Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten. + + {isMultipartMergeJob + ? 'Entfernt diesen Merge-Job aus der Verlaufsliste. Im nächsten Schritt kann gewählt werden, ob die Merge-Datei ebenfalls gelöscht werden soll.' + : 'Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten.'} +
- {multipartMergePreviewLoading ? ( - Vorschau wird aktualisiert ... - ) : null} -
- 1. CLI-Befehl - {multipartMergeCommandPreview ? ( - {multipartMergeCommandPreview} - ) : ( - Noch keine Vorschau verfügbar. - )} -
-
- 2. Kapitel (gesamt) - {multipartMergeChapterEntries.length > 0 ? ( -
- {multipartMergeChapterEntries.map((entry) => ( -
- #{entry.index} - {entry.startClock || entry.start || '-'} - {entry.title || '-'} -
- ))} -
- ) : ( - Keine berechnete Kapitel-Gesamtliste verfügbar. - )} -
-
- 3. Erwartete Medieninfos (nach Merge) -
-
- Audio - {multipartMergeAudioTracks.length > 0 ? ( -
- {multipartMergeAudioTracks.map((track) => ( - - {formatMergeTrackSummary(track, 'Audio')} - - ))} -
- ) : ( - Keine Audio-Infos. - )} -
-
- Untertitel - {multipartMergeSubtitleTracks.length > 0 ? ( -
- {multipartMergeSubtitleTracks.map((track) => ( - - {formatMergeTrackSummary(track, 'UT')} - - ))} -
- ) : ( - Keine Untertitel-Infos. - )} -
+ {!isMultipartMergeRunning ? ( + <> +
+ Merge-Quellen + Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.
- {multipartMergePreview?.media?.available ? ( - - {multipartMergeMediaAligned - ? 'Track-Layout über alle Quellen konsistent.' - : `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`} - + {hasMultipartMergeSources ? ( +
+ {multipartMergeSources.map((source, sourceIndex) => { + const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`; + const canReorder = !busyJobIds.has(jobId) + && !processingStates.includes(normalizedStatus) + && multipartMergeSources.length > 1; + return ( +
{ + if (!canReorder) return; + setDraggingMergeSource({ jobId, sourceJobId: source.sourceJobId }); + }} + onDragEnd={() => setDraggingMergeSource(null)} + onDragOver={(event) => { + if (!canReorder) return; + if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return; + event.preventDefault(); + }} + onDrop={(event) => { + if (!canReorder) return; + if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return; + event.preventDefault(); + const draggedId = normalizeJobId(draggingMergeSource.sourceJobId); + const targetId = normalizeJobId(source.sourceJobId); + if (!draggedId || !targetId || draggedId === targetId) { + setDraggingMergeSource(null); + return; + } + const currentOrder = multipartMergeSources + .map((item) => normalizeJobId(item.sourceJobId)) + .filter(Boolean); + const fromIndex = currentOrder.findIndex((id) => id === draggedId); + const toIndex = currentOrder.findIndex((id) => id === targetId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + setDraggingMergeSource(null); + return; + } + const nextOrder = [...currentOrder]; + const [moved] = nextOrder.splice(fromIndex, 1); + nextOrder.splice(toIndex, 0, moved); + setDraggingMergeSource(null); + handleReorderMultipartMergeSources(jobId, nextOrder); + }} + > +
+ + + +
+
+ {source.fileName || source.outputPath} + {source.outputPath} +
+
+ ); + })} +
) : ( - Stream-Vergleich noch nicht verfügbar. +

Noch keine Merge-Quellen gefunden.

)} + + ) : null} + {isMultipartMergeRunning ? ( + <> +
+ + {String(pipelineForJob?.statusText || '').trim() || 'Merge läuft'} +
+
+ progressLabel} + /> + + {etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel} + +
+
+

Aktueller Merge-Log

+
+                                  {multipartMergeLiveLog
+                                    || (multipartMergeLiveLogLoading ? 'Live-Log wird geladen ...' : 'Noch keine Log-Ausgabe vorhanden.')}
+                                
+
+ + ) : ( +
+
+ Merge Vorschau +
+ {multipartMergePreviewLoading ? ( + Vorschau wird aktualisiert ... + ) : null} +
+ 1. CLI-Befehl + {multipartMergeCommandPreview ? ( + {multipartMergeCommandPreview} + ) : ( + Noch keine Vorschau verfügbar. + )} +
+
+ 2. Kapitel (gesamt) + {multipartMergeChapterEntries.length > 0 ? ( +
+ {multipartMergeChapterEntries.map((entry) => ( +
+ #{entry.index} + {entry.startClock || entry.start || '-'} + {entry.title || '-'} +
+ ))} +
+ ) : ( + Keine berechnete Kapitel-Gesamtliste verfügbar. + )} +
+
+ 3. Erwartete Medieninfos (nach Merge) +
+
+ Audio + {multipartMergeAudioTracks.length > 0 ? ( +
+ {multipartMergeAudioTracks.map((track) => ( + + {formatMergeTrackSummary(track, 'Audio')} + + ))} +
+ ) : ( + Keine Audio-Infos. + )} +
+
+ Untertitel + {multipartMergeSubtitleTracks.length > 0 ? ( +
+ {multipartMergeSubtitleTracks.map((track) => ( + + {formatMergeTrackSummary(track, 'UT')} + + ))} +
+ ) : ( + Keine Untertitel-Infos. + )} +
+
+ {multipartMergePreview?.media?.available ? ( + + {multipartMergeMediaAligned + ? 'Track-Layout über alle Quellen konsistent.' + : `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`} + + ) : ( + Stream-Vergleich noch nicht verfügbar. + )} +
-
+ )}
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
) : ( @@ -3753,7 +3886,7 @@ export default function RipperPage({ {item.hasChains ? : null} {item.subtitle ? {item.subtitle} : null} - #{item.jobId} | {getStatusLabel(item.status)} + #{item.jobId} | {getMergeAwareStatusLabel(item, item.status)} {clampedQueueProgress > 0 && ( )} @@ -3794,7 +3927,7 @@ export default function RipperPage({ {item.hasScripts ? : null} {item.hasChains ? : null} - {getStatusLabel(item.status)} + {getMergeAwareStatusLabel(item, item.status)} {hasScriptSummary ? (