From 5aa65ad4c16d714a2470b2cd34f1d762240f732e Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 31 Mar 2026 12:51:32 +0000 Subject: [PATCH] 0.12.0-18 Tests and Converter Fixes --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/plugins/ConverterPlugin.js | 54 +- backend/src/routes/pipelineRoutes.js | 5 +- backend/src/services/historyService.js | 64 +- backend/src/services/pipelineService.js | 906 +++++++++++++++--- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 2 + frontend/src/api/client.js | 44 +- .../src/components/ConverterFileExplorer.jsx | 18 + frontend/src/components/ConverterJobCard.jsx | 10 +- frontend/src/components/CronJobsTab.jsx | 10 +- .../src/components/MediaInfoReviewPanel.jsx | 30 +- .../src/components/PipelineStatusCard.jsx | 355 ++++++- frontend/src/pages/ConverterPage.jsx | 753 ++++++++++++++- frontend/src/pages/DashboardPage.jsx | 100 +- frontend/src/pages/DatabasePage.jsx | 8 +- frontend/src/pages/DownloadsPage.jsx | 9 +- frontend/src/pages/HistoryPage.jsx | 85 +- frontend/src/pages/SettingsPage.jsx | 18 +- frontend/src/utils/confirmModal.js | 41 + package-lock.json | 4 +- package.json | 2 +- 24 files changed, 2281 insertions(+), 249 deletions(-) create mode 100644 frontend/src/utils/confirmModal.js diff --git a/backend/package-lock.json b/backend/package-lock.json index c9b50ef..6a65414 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-17", + "version": "0.12.0-18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-17", + "version": "0.12.0-18", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 0927322..07e5052 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-17", + "version": "0.12.0-18", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js index ba35f90..687e9ac 100644 --- a/backend/src/plugins/ConverterPlugin.js +++ b/backend/src/plugins/ConverterPlugin.js @@ -339,7 +339,13 @@ class ConverterPlugin extends SourcePlugin { cmd: handBrakeConfig.cmd, args: handBrakeConfig.args, jobId: job?.id, progressParser: parseHandBrakeProgress, - onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `Encoding ${pct}%`), + onProgress: (pct, statusText, eta) => ctx.emitProgress( + Math.round(pct), + statusText || `Encoding ${pct}%`, + eta ?? null + ), + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'HANDBRAKE', isCancelled, onProcessHandle, context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } @@ -451,6 +457,8 @@ class ConverterPlugin extends SourcePlugin { await _spawnAndWait({ cmd: encodeConfig.cmd, args: encodeConfig.args, jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', isCancelled, onProcessHandle, context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } @@ -504,6 +512,8 @@ class ConverterPlugin extends SourcePlugin { await _spawnAndWait({ cmd: encodeConfig.cmd, args: encodeConfig.args, jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', isCancelled, onProcessHandle, context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } @@ -536,6 +546,8 @@ class ConverterPlugin extends SourcePlugin { await _spawnAndWait({ cmd: encodeConfig.cmd, args: encodeConfig.args, jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', isCancelled, onProcessHandle, context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } @@ -591,25 +603,45 @@ class ConverterPlugin extends SourcePlugin { } } -async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) { +async function _spawnAndWait({ + cmd, + args, + jobId, + progressParser, + onProgress, + appendLogLine, + logSource = 'PROCESS', + isCancelled, + onProcessHandle, + context +}) { if (typeof isCancelled === 'function' && isCancelled()) { const err = new Error('Job wurde vom Benutzer abgebrochen.'); err.statusCode = 409; throw err; } + const handleLine = (stream, line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent, progress.detail || null, progress.eta ?? null); + } + } + if (typeof appendLogLine === 'function') { + try { + appendLogLine(logSource, line, stream); + } catch (_error) { + // ignore log append errors in process stream + } + } + }; + const handle = spawnTrackedProcess({ cmd, args, - onStdoutLine: () => {}, - onStderrLine: (line) => { - if (progressParser && typeof onProgress === 'function') { - const progress = progressParser(line); - if (progress?.percent != null) { - onProgress(progress.percent, progress.detail || null); - } - } - }, + onStdoutLine: (line) => handleLine('stdout', line), + onStderrLine: (line) => handleLine('stderr', line), context: context || { jobId } }); diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 181a706..6cb660a 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -280,6 +280,7 @@ router.post( const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds; const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate); const selectedUserPresetId = req.body?.selectedUserPresetId ?? null; + const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null; logger.info('post:confirm-encode', { reqId: req.reqId, jobId, @@ -287,6 +288,7 @@ router.post( selectedTrackSelectionProvided: Boolean(selectedTrackSelection), skipPipelineStateUpdate, selectedUserPresetId, + selectedHandBrakePreset, selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) ? selectedPostEncodeScriptIds.length : 0, @@ -308,7 +310,8 @@ router.post( selectedPostEncodeChainIds, selectedPreEncodeChainIds, skipPipelineStateUpdate, - selectedUserPresetId + selectedUserPresetId, + selectedHandBrakePreset }); res.json({ job }); }) diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 9851119..95a59ef 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -22,7 +22,7 @@ function parseJsonSafe(raw, fallback = null) { const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024; const processLogStreams = new Map(); -const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other']; +const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'converter', 'other']; const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i; @@ -329,7 +329,27 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn // Converter jobs: detected via media_type field (plan.mediaProfile = 'converter') const rawMediaType = normalizeMediaTypeValue(job?.media_type); const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); - if (rawPlanProfile === 'converter' || rawMediaType === 'converter') { + const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase(); + const hasConverterPathHint = (value) => { + const normalized = String(value || '') + .trim() + .replace(/\\/g, '/') + .toLowerCase(); + if (!normalized) { + return false; + } + return /(^|\/)converter(\/|$)/.test(normalized); + }; + if ( + rawPlanProfile === 'converter' + || rawMediaType === 'converter' + || converterMediaTypeHint === 'video' + || converterMediaTypeHint === 'audio' + || converterMediaTypeHint === 'iso' + || hasConverterPathHint(rawPath) + || hasConverterPathHint(encodeInputPath) + || hasConverterPathHint(plan?.inputPath) + ) { return 'converter'; } @@ -630,12 +650,17 @@ function enrichJobRow(job, settings = null, options = {}) { const mediainfoInfo = resolvedPaths.mediainfoInfo; const encodePlan = resolvedPaths.encodePlan; const mediaType = resolvedPaths.mediaType; + const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); const ripSuccessful = Number(job?.rip_successful || 0) === 1 || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const backupSuccess = ripSuccessful; + const finishedWithOutput = normalizedJobStatus === 'FINISHED' + && (includeFsChecks ? Boolean(outputStatus?.exists) : true); const encodeSuccess = directoryLikeOutput - ? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists)) - : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + ? finishedWithOutput + : (mediaType === 'converter' + ? finishedWithOutput + : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'); return { ...job, @@ -3067,7 +3092,33 @@ class HistoryService { ` SELECT * FROM jobs - WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'READY_TO_ENCODE', 'CD_ANALYZING', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING') + WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') + ORDER BY updated_at ASC, id ASC + ` + ), + settingsService.getSettingsMap() + ]); + return rows.map((job) => ({ + ...enrichJobRow(job, settings), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async getQueueIdleJobs() { + const db = await getDb(); + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE status IN ( + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'READY_TO_ENCODE', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP' + ) ORDER BY updated_at ASC, id ASC ` ), @@ -4041,6 +4092,7 @@ class HistoryService { } const artifactMoviePathSet = new Set(artifactMoviePaths); + const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter'; for (const outputPath of explicitMoviePaths) { addCandidate( movieCandidates, @@ -4050,7 +4102,7 @@ class HistoryService { movieAllowedPaths ); const parentDir = toNormalizedPath(path.dirname(outputPath)); - if (parentDir && !movieRoots.includes(parentDir)) { + if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) { addCandidate( movieCandidates, 'movie', diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index fda8f81..2ac160b 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -463,11 +463,25 @@ function listTopLevelExtensionlessDvdImages(dirPath) { return results; } +function hasConverterPathSegment(value) { + const normalized = String(value || '') + .trim() + .replace(/\\/g, '/') + .toLowerCase(); + if (!normalized) { + return false; + } + return /(^|\/)converter(\/|$)/.test(normalized); +} + function inferMediaProfileFromRawPath(rawPath) { const source = String(rawPath || '').trim(); if (!source) { return null; } + if (hasConverterPathSegment(source)) { + return 'converter'; + } try { const sourceStat = fs.statSync(source); if (sourceStat.isFile()) { @@ -4572,11 +4586,11 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP const sourceStat = fs.statSync(sourcePath); if (sourceStat.isFile()) { const ext = path.extname(sourcePath).toLowerCase(); + const supportedSingleFileExts = new Set([ + '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' + ]); if ( - ext === '.aax' - || ext === '.m4b' - || ext === '.mkv' - || ext === '.mp4' + supportedSingleFileExts.has(ext) || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size) ) { return { @@ -4604,7 +4618,9 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP }; } - const primary = findMediaFiles(sourcePath, ['.aax', '.m4b', '.mkv', '.mp4']); + const primary = findMediaFiles(sourcePath, [ + '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' + ]); if (primary.length > 0) { return { mediaFiles: primary, @@ -6587,6 +6603,94 @@ class PipelineService extends EventEmitter { return value === 'true' || value === true; } + normalizeQueuePoolType(rawValue) { + const value = String(rawValue || '').trim().toLowerCase(); + if (value === 'audio' || value === 'cd') { + return 'audio'; + } + if (value === 'film' || value === 'video') { + return 'film'; + } + return null; + } + + resolveQueuePoolTypeForJob(job = null) { + if (!job || typeof job !== 'object') { + return 'film'; + } + + const explicitMediaType = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); + const encodePlan = this.extractQueueJobPlan(job) || this.safeParseJson(job?.encode_plan_json) || {}; + const makemkvInfo = job?.makemkvInfo || this.safeParseJson(job?.makemkv_info_json); + const converterMediaType = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); + + if (explicitMediaType === 'cd') { + return 'audio'; + } + if (explicitMediaType === 'audiobook') { + return 'audio'; + } + if (explicitMediaType === 'converter' && converterMediaType === 'audio') { + return 'audio'; + } + + const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: explicitMediaType || null + }); + if (resolvedMediaProfile === 'cd') { + return 'audio'; + } + if (resolvedMediaProfile === 'audiobook') { + return 'audio'; + } + if (resolvedMediaProfile === 'converter' && converterMediaType === 'audio') { + return 'audio'; + } + + return 'film'; + } + + resolveQueuePoolTypeForEntry(entry = null, jobsById = null) { + const explicitPoolType = this.normalizeQueuePoolType(entry?.poolType); + if (explicitPoolType) { + return explicitPoolType; + } + if (String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_CD) { + return 'audio'; + } + const jobId = Number(entry?.jobId); + if (Number.isFinite(jobId) && jobId > 0) { + const sourceJob = jobsById instanceof Map + ? (jobsById.get(Math.trunc(jobId)) || null) + : null; + if (sourceJob) { + return this.resolveQueuePoolTypeForJob(sourceJob); + } + } + return 'film'; + } + + buildRunningPoolUsage(runningJobs = []) { + const rows = Array.isArray(runningJobs) ? runningJobs : []; + let filmRunning = 0; + let audioRunning = 0; + for (const job of rows) { + const poolType = this.resolveQueuePoolTypeForJob(job); + if (poolType === 'audio') { + audioRunning += 1; + } else { + filmRunning += 1; + } + } + return { + filmRunning, + audioRunning, + totalRunning: filmRunning + audioRunning + }; + } + findQueueEntryIndexByJobId(jobId) { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } @@ -6793,13 +6897,30 @@ class PipelineService extends EventEmitter { this.getMaxTotalEncodes(), this.getCdBypassesQueue() ]); - const runningJobs = await historyService.getRunningJobs(); - const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length; - const runningCdCount = runningJobs.filter((job) => ['CD_RIPPING', 'CD_ENCODING'].includes(job.status)).length; + const [runningJobs, idleJobsRaw] = await Promise.all([ + historyService.getRunningJobs(), + historyService.getQueueIdleJobs() + ]); + const runningPoolUsage = this.buildRunningPoolUsage(runningJobs); + const runningEncodeCount = runningPoolUsage.filmRunning; + const runningCdCount = runningPoolUsage.audioRunning; const queuedJobIds = this.queueEntries .filter((entry) => !entry.type || entry.type === 'job') .map((entry) => Number(entry.jobId)) .filter((id) => Number.isFinite(id) && id > 0); + const queuedJobIdSet = new Set(queuedJobIds); + const runningJobIdSet = new Set( + runningJobs + .map((job) => Number(job?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + ); + const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { + const jobId = Number(job?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + return false; + } + return !queuedJobIdSet.has(jobId) && !runningJobIdSet.has(jobId); + }); const queuedRows = queuedJobIds.length > 0 ? await historyService.getJobsByIds(queuedJobIds) : []; @@ -6807,7 +6928,7 @@ class PipelineService extends EventEmitter { const scriptMetaByJobId = await this.buildQueueJobScriptMeta( Array.from( new Map( - [...runningJobs, ...queuedRows].map((row) => [Number(row?.id), row]) + [...runningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row]) ).values() ) ); @@ -6819,11 +6940,23 @@ class PipelineService extends EventEmitter { cdBypassesQueue, runningCount: runningEncodeCount, runningCdCount, + idleCount: idleJobs.length, runningJobs: runningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, status: job.status, lastState: job.last_state || null, + poolType: this.resolveQueuePoolTypeForJob(job), + hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), + hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), + scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null + })), + idleJobs: idleJobs.map((job) => ({ + jobId: Number(job.id), + title: job.title || job.detected_title || `Job #${job.id}`, + status: job.status, + lastState: job.last_state || null, + poolType: this.resolveQueuePoolTypeForJob(job), hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null @@ -6855,6 +6988,7 @@ class PipelineService extends EventEmitter { jobId: Number(entry.jobId), action: entry.action, actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, + poolType: this.resolveQueuePoolTypeForEntry(entry, queuedById), title: row?.title || row?.detected_title || `Job #${entry.jobId}`, status: row?.status || null, lastState: row?.last_state || null, @@ -6978,7 +7112,7 @@ class PipelineService extends EventEmitter { return this.lastQueueSnapshot; } - async enqueueOrStartAction(action, jobId, startNow) { + async enqueueOrStartAction(action, jobId, startNow, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID für Queue-Aktion.'); @@ -6996,26 +7130,46 @@ class PipelineService extends EventEmitter { throw error; } + let poolType = this.normalizeQueuePoolType(options?.poolType); + if (!poolType) { + const poolJob = await historyService.getJobById(normalizedJobId).catch(() => null); + poolType = this.resolveQueuePoolTypeForJob(poolJob); + } + const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (existingQueueIndex >= 0) { return { queued: true, started: false, queuePosition: existingQueueIndex + 1, - action + action, + poolType }; } - const [maxFilm, maxTotal] = await Promise.all([ + const [maxFilm, maxCd, maxTotal, cdBypass, runningJobs] = await Promise.all([ this.getMaxParallelJobs(), - this.getMaxTotalEncodes() + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue(), + historyService.getRunningJobs() ]); - const [filmRunning, cdRunning] = await Promise.all([ - historyService.getRunningFilmEncodeJobs().then((r) => r.length), - historyService.getRunningCdEncodeJobs().then((r) => r.length) - ]); - const totalRunning = filmRunning + cdRunning; - const shouldQueue = this.queueEntries.length > 0 || filmRunning >= maxFilm || totalRunning >= maxTotal; + const { filmRunning, audioRunning, totalRunning } = this.buildRunningPoolUsage(runningJobs); + const queueJobEntries = this.queueEntries.filter((entry) => !entry.type || entry.type === 'job'); + const queuedPoolTypeCount = queueJobEntries.filter( + (entry) => this.resolveQueuePoolTypeForEntry(entry) === poolType + ).length; + const hasBlockingAudioQueueEntry = this.queueEntries.some((entry) => { + if (entry?.type && entry.type !== 'job') { + return true; + } + return this.resolveQueuePoolTypeForEntry(entry) === 'audio'; + }); + const laneRunning = poolType === 'audio' ? audioRunning : filmRunning; + const laneCap = poolType === 'audio' ? maxCd : maxFilm; + const shouldQueue = poolType === 'audio' && cdBypass + ? (hasBlockingAudioQueueEntry || laneRunning >= laneCap || totalRunning >= maxTotal) + : (this.queueEntries.length > 0 || laneRunning >= laneCap || totalRunning >= maxTotal); if (!shouldQueue) { const result = await startNow(); await this.emitQueueChanged(); @@ -7023,6 +7177,7 @@ class PipelineService extends EventEmitter { queued: false, started: true, action, + poolType, ...(result && typeof result === 'object' ? result : {}) }; } @@ -7031,6 +7186,8 @@ class PipelineService extends EventEmitter { id: this.queueEntrySeq++, jobId: normalizedJobId, action, + poolType, + ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), enqueuedAt: nowIso() }); await historyService.appendLog( @@ -7045,86 +7202,22 @@ class PipelineService extends EventEmitter { queued: true, started: false, queuePosition: this.queueEntries.length, - action + action, + poolType, + queuedPoolTypeCount }; } async enqueueOrStartCdAction(jobId, ripConfig, startNow) { - const normalizedJobId = this.normalizeQueueJobId(jobId); - if (!normalizedJobId) { - const error = new Error('Ungültige Job-ID für CD Queue-Aktion.'); - error.statusCode = 400; - throw error; - } - if (typeof startNow !== 'function') { - const error = new Error('CD Queue-Aktion kann nicht gestartet werden (startNow fehlt).'); - error.statusCode = 500; - throw error; - } - - const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); - if (existingQueueIndex >= 0) { - return { - queued: true, - started: false, - queuePosition: existingQueueIndex + 1, - action: QUEUE_ACTIONS.START_CD - }; - } - - const [maxCd, maxTotal, cdBypass] = await Promise.all([ - this.getMaxParallelCdEncodes(), - this.getMaxTotalEncodes(), - this.getCdBypassesQueue() - ]); - const [filmRunning, cdRunning] = await Promise.all([ - historyService.getRunningFilmEncodeJobs().then((r) => r.length), - historyService.getRunningCdEncodeJobs().then((r) => r.length) - ]); - const totalRunning = filmRunning + cdRunning; - - let shouldQueue; - if (cdBypass) { - const cdQueueLength = this.queueEntries.filter( - (e) => (!e.type || e.type === 'job') && e.action === QUEUE_ACTIONS.START_CD - ).length; - shouldQueue = cdQueueLength > 0 || cdRunning >= maxCd || totalRunning >= maxTotal; - } else { - shouldQueue = this.queueEntries.length > 0 || cdRunning >= maxCd || totalRunning >= maxTotal; - } - - if (!shouldQueue) { - const result = await startNow(); - await this.emitQueueChanged(); - return { - queued: false, - started: true, - action: QUEUE_ACTIONS.START_CD, - ...(result && typeof result === 'object' ? result : {}) - }; - } - - this.queueEntries.push({ - id: this.queueEntrySeq++, - jobId: normalizedJobId, - action: QUEUE_ACTIONS.START_CD, - ripConfig: ripConfig || {}, - enqueuedAt: nowIso() - }); - await historyService.appendLog( - normalizedJobId, - 'USER_ACTION', - `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[QUEUE_ACTIONS.START_CD]}` + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_CD, + jobId, + startNow, + { + poolType: 'audio', + entryData: { ripConfig: ripConfig || {} } + } ); - await this.emitQueueChanged(); - void this.pumpQueue(); - - return { - queued: true, - started: false, - queuePosition: this.queueEntries.length, - action: QUEUE_ACTIONS.START_CD - }; } async dispatchNonJobEntry(entry) { @@ -7282,13 +7375,12 @@ class PipelineService extends EventEmitter { this.getMaxTotalEncodes(), this.getCdBypassesQueue() ]); - const filmRunning = allRunningJobs.filter((j) => j.status === 'ENCODING').length; - const cdRunning = allRunningJobs.filter((j) => ['CD_RIPPING', 'CD_ENCODING'].includes(j.status)).length; - const totalRunning = filmRunning + cdRunning; + const runningUsage = this.buildRunningPoolUsage(allRunningJobs); + const filmRunning = runningUsage.filmRunning; + const cdRunning = runningUsage.audioRunning; + const totalRunning = runningUsage.totalRunning; // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE). - const anyActiveJobs = allRunningJobs.filter( - (j) => !['READY_TO_ENCODE', 'CD_READY_TO_RIP'].includes(j.status) - ).length; + const anyActiveJobs = totalRunning; // Find next startable entry let entryIndex = -1; @@ -7312,8 +7404,8 @@ class PipelineService extends EventEmitter { break; } - const isCdEntry = candidate.action === QUEUE_ACTIONS.START_CD; - if (isCdEntry) { + const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); + if (candidatePoolType === 'audio') { if (cdRunning < maxCd) { entryIndex = i; break; @@ -7920,6 +8012,22 @@ class PipelineService extends EventEmitter { if (profileFromPlan) { return profileFromPlan; } + const converterMediaTypeHint = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaTypeHint === 'video' || converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'iso') { + return 'converter'; + } + if (String(encodePlan?.mode || '').trim().toLowerCase() === 'converter') { + return 'converter'; + } + if ( + hasConverterPathSegment(encodePlan?.inputPath) + || hasConverterPathSegment(encodePlan?.encodeInputPath) + || hasConverterPathSegment(job?.raw_path) + || hasConverterPathSegment(options?.rawPath) + || hasConverterPathSegment(job?.encode_input_path) + ) { + return 'converter'; + } const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object' ? options.makemkvInfo @@ -9603,7 +9711,148 @@ class PipelineService extends EventEmitter { throw error; } const mkInfo = this.safeParseJson(job.makemkv_info_json); - const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); + const encodePlanForProfile = this.safeParseJson(job.encode_plan_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo, encodePlan: encodePlanForProfile }); + if (mediaProfile === 'converter') { + const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterInputPath = String(converterPlan?.inputPath || job?.raw_path || '').trim(); + if (!converterInputPath || !fs.existsSync(converterInputPath)) { + const error = new Error(`Converter-Eingabedatei nicht gefunden: ${converterInputPath || '-'}`); + error.statusCode = 404; + throw error; + } + + const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); + const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const waitingForDecision = ( + String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + || String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + ); + const hasExplicitMetadataPayload = ( + title !== undefined + || year !== undefined + || imdbId !== undefined + || poster !== undefined + || (fromOmdb !== null && fromOmdb !== undefined) + ); + + if ((normalizedSelectedPlaylist || normalizedSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + mediainfo_info_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + normalizedSelectedHandBrakeTitleId + ? `Converter Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` + : `Converter Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` + ); + + try { + await this.runMediainfoReviewForJob(jobId, converterInputPath, { + mode: 'converter', + mediaProfile: 'converter', + selectedPlaylist: normalizedSelectedPlaylist || null, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, + previousEncodePlan: converterPlan + }); + } catch (error) { + logger.error('metadata:converter:selection:review-failed', { + jobId, + selectedPlaylist: normalizedSelectedPlaylist || null, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, + error: errorToMeta(error) + }); + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + throw error; + } + return historyService.getJobById(jobId); + } + + const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0; + const effectiveTitle = hasTitleInput + ? String(title).trim() + : (job.title || job.detected_title || path.basename(converterInputPath, path.extname(converterInputPath)) || 'Converter Job'); + const hasYearInput = year !== undefined && year !== null && String(year).trim() !== ''; + let effectiveYear = job.year ?? null; + if (hasYearInput) { + const parsedYear = Number(year); + effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; + } + const effectiveImdbId = imdbId === undefined + ? (job.imdb_id || null) + : (imdbId || null); + const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined + ? Number(job.selected_from_omdb || 0) + : (fromOmdb ? 1 : 0); + const posterValue = poster === undefined + ? (job.poster_url || null) + : (poster || null); + + let omdbJsonValue = job.omdb_json || null; + if (fromOmdb && effectiveImdbId) { + try { + const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); + if (omdbFull?.raw) { + omdbJsonValue = JSON.stringify(omdbFull.raw); + } + } catch (omdbErr) { + logger.warn('metadata:converter:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); + } + } + + const nextEncodePlan = { + ...converterPlan, + mediaProfile: 'converter', + converterMediaType: String(converterPlan?.converterMediaType || '').trim().toLowerCase() || 'video', + inputPath: converterInputPath, + metadata: { + ...(converterPlan?.metadata && typeof converterPlan.metadata === 'object' ? converterPlan.metadata : {}), + title: effectiveTitle, + year: effectiveYear, + imdbId: effectiveImdbId, + poster: posterValue + }, + reviewConfirmed: false + }; + + await historyService.updateJob(jobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + raw_path: converterInputPath, + media_type: 'converter', + encode_plan_json: JSON.stringify(nextEncodePlan), + encode_review_confirmed: 0, + error_message: null + }); + + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + historyService.queuePosterCache(jobId, posterValue, { + source: 'Poster', + logFailures: true + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Converter-Metadaten übernommen. Starte Mediainfo-Prüfung mit Titel-/Spurauswahl.' + ); + + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); @@ -9983,7 +10232,12 @@ class PipelineService extends EventEmitter { return this.startAudiobookEncode(jobId, { ...options, preloadedJob }); } if (preloadedMediaProfile === 'converter') { - return this.startConverterEncode(jobId, { ...options, preloadedJob }); + const preloadedPlan = this.safeParseJson(preloadedJob.encode_plan_json) || {}; + const converterMediaType = String(preloadedPlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType === 'audio') { + return this.startConverterEncode(jobId, { ...options, preloadedJob }); + } + return this.startPreparedConverterVideoJob(jobId, { ...options, preloadedJob }); } const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; @@ -10044,7 +10298,12 @@ class PipelineService extends EventEmitter { return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job }); } if (jobMediaProfile === 'converter') { - return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job }); + const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterMediaType = String(converterPlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType === 'audio') { + return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job }); + } + return this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }); } this.ensureNotBusy('startPreparedJob', jobId); @@ -10268,6 +10527,127 @@ class PipelineService extends EventEmitter { return { started: true, stage: 'RIPPING' }; } + async startPreparedConverterVideoJob(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + const preloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId) + ? options.preloadedJob + : null; + const job = preloadedJob || await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const statusUpper = String(job.status || '').trim().toUpperCase(); + const lastStateUpper = String(job.last_state || '').trim().toUpperCase(); + const isReadyToEncode = statusUpper === 'READY_TO_ENCODE' || lastStateUpper === 'READY_TO_ENCODE'; + + if (isReadyToEncode) { + if (!Number(job.encode_review_confirmed || 0)) { + const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.'); + error.statusCode = 409; + throw error; + } + if (!immediate) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }) + ); + } + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + error_message: null, + end_time: null + }); + + this.startConverterEncode(jobId, { immediate: true, preloadedJob: job }).catch((error) => { + logger.error('startPreparedConverterVideoJob:encode-background-failed', { + jobId, + error: errorToMeta(error) + }); + if (error?.jobAlreadyFailed) { + return; + } + this.failJob(jobId, 'ENCODING', error).catch((failError) => { + logger.error('startPreparedConverterVideoJob:encode-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { started: true, stage: 'ENCODING' }; + } + + if (statusUpper === 'METADATA_SELECTION') { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten-Auswahl übersprungen. Starte Review ohne OMDb-Zuordnung.' + ); + } + if (statusUpper === 'WAITING_FOR_USER_DECISION') { + const error = new Error('Start nicht möglich: Bitte zuerst die erforderliche Titel/Playlist-Auswahl treffen.'); + error.statusCode = 409; + throw error; + } + + const inputPath = String(encodePlan?.inputPath || job?.raw_path || '').trim(); + if (!inputPath || !fs.existsSync(inputPath)) { + const error = new Error(`Mediainfo-Prüfung nicht möglich: Eingabedatei fehlt (${inputPath || '-'})`); + error.statusCode = 400; + throw error; + } + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + start_time: nowIso(), + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Converter-Review gestartet (Video): ${path.basename(inputPath)}` + ); + + this.runMediainfoReviewForJob(jobId, inputPath, { + mode: 'converter', + mediaProfile: 'converter', + previousEncodePlan: encodePlan + }).catch((error) => { + logger.error('startPreparedConverterVideoJob:review-background-failed', { + jobId, + error: errorToMeta(error) + }); + this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('startPreparedConverterVideoJob:review-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { + started: true, + stage: 'MEDIAINFO_CHECK', + reusedRaw: true, + rawPath: inputPath + }; + } + async confirmEncodeReview(jobId, options = {}) { this.ensureNotBusy('confirmEncodeReview', jobId); const skipPipelineStateUpdate = Boolean(options?.skipPipelineStateUpdate); @@ -10276,6 +10656,7 @@ class PipelineService extends EventEmitter { selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), skipPipelineStateUpdate, + selectedHandBrakePreset: options?.selectedHandBrakePreset ?? null, selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds) ? options.selectedPostEncodeScriptIds.length : 0 @@ -10381,6 +10762,10 @@ class PipelineService extends EventEmitter { // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); + const hasExplicitHandBrakePresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); + const rawHandBrakePreset = hasExplicitHandBrakePresetSelection + ? String(options?.selectedHandBrakePreset || '').trim() + : ''; let resolvedUserPreset = null; if (hasExplicitUserPresetSelection) { const rawUserPresetId = options?.selectedUserPresetId; @@ -10395,7 +10780,17 @@ class PipelineService extends EventEmitter { throw error; } } - } else { + } + if ((!resolvedUserPreset || !hasExplicitUserPresetSelection) && hasExplicitHandBrakePresetSelection) { + resolvedUserPreset = rawHandBrakePreset + ? { + name: `HandBrake: ${rawHandBrakePreset}`, + handbrakePreset: rawHandBrakePreset, + extraArgs: '' + } + : null; + } + if (!hasExplicitUserPresetSelection && !hasExplicitHandBrakePresetSelection) { resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null); } @@ -10420,6 +10815,11 @@ class PipelineService extends EventEmitter { const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: confirmedPlan }); + if (readyMediaProfile === 'converter' && !confirmedPlan.userPreset) { + const error = new Error('Bestätigung nicht möglich: Bitte ein User- oder HandBrake-Preset auswählen.'); + error.statusCode = 400; + throw error; + } const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings( confirmSettings, @@ -11176,6 +11576,16 @@ class PipelineService extends EventEmitter { error.statusCode = 400; throw error; } + const isConverterReview = String(options?.mode || '').trim().toLowerCase() === 'converter' + || String(mediaProfile || '').trim().toLowerCase() === 'converter'; + const reviewSettings = isConverterReview + ? { + ...settings, + makemkv_min_length_minutes: 0, + handbrake_preset: '', + handbrake_extra_args: '' + } + : settings; await historyService.appendLog( jobId, 'SYSTEM', @@ -11246,7 +11656,7 @@ class PipelineService extends EventEmitter { ...buildMediainfoReview({ mediaFiles: processedFiles, mediaInfoByPath, - settings, + settings: reviewSettings, presetProfile, playlistAnalysis, preferredEncodeTitleId, @@ -11465,7 +11875,7 @@ class PipelineService extends EventEmitter { const review = buildMediainfoReview({ mediaFiles: effectiveMediaFiles, mediaInfoByPath, - settings, + settings: reviewSettings, presetProfile, playlistAnalysis, preferredEncodeTitleId, @@ -11490,6 +11900,59 @@ class PipelineService extends EventEmitter { : null ); enrichedReview = reviewPrefillResult.plan; + const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null; + const converterReviewMode = String(options?.mode || '').trim().toLowerCase() === 'converter' + || String(options?.mediaProfile || '').trim().toLowerCase() === 'converter' + || String(previousEncodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter'; + if (converterReviewMode && previousEncodePlan) { + const normalizeConverterItem = (item) => { + if (!item || typeof item !== 'object') return null; + const type = String(item?.type || '').trim().toLowerCase(); + const id = Number(item?.id); + if ((type !== 'script' && type !== 'chain') || !Number.isFinite(id) || id <= 0) { + return null; + } + return { type, id: Math.trunc(id) }; + }; + const preservedPreItems = Array.isArray(previousEncodePlan?.preEncodeItems) + ? previousEncodePlan.preEncodeItems.map(normalizeConverterItem).filter(Boolean) + : []; + const preservedPostItems = Array.isArray(previousEncodePlan?.postEncodeItems) + ? previousEncodePlan.postEncodeItems.map(normalizeConverterItem).filter(Boolean) + : []; + enrichedReview = { + ...enrichedReview, + mode: 'converter', + mediaProfile: 'converter', + converterMediaType: String(previousEncodePlan?.converterMediaType || '').trim().toLowerCase() || 'video', + inputPath: String(previousEncodePlan?.inputPath || job?.raw_path || rawPath || '').trim() || null, + inputPaths: Array.isArray(previousEncodePlan?.inputPaths) ? previousEncodePlan.inputPaths : null, + outputPath: String(previousEncodePlan?.outputPath || '').trim() || null, + outputDir: String(previousEncodePlan?.outputDir || '').trim() || null, + outputFormat: String(previousEncodePlan?.outputFormat || '').trim().toLowerCase() || null, + userPreset: previousEncodePlan?.userPreset || null, + audioFormatOptions: previousEncodePlan?.audioFormatOptions && typeof previousEncodePlan.audioFormatOptions === 'object' + ? previousEncodePlan.audioFormatOptions + : {}, + audioTrackTemplate: String(previousEncodePlan?.audioTrackTemplate || '').trim() || null, + audioFiles: Array.isArray(previousEncodePlan?.audioFiles) ? previousEncodePlan.audioFiles : null, + metadata: previousEncodePlan?.metadata && typeof previousEncodePlan.metadata === 'object' + ? previousEncodePlan.metadata + : null, + tracks: Array.isArray(previousEncodePlan?.tracks) ? previousEncodePlan.tracks : null, + isFolder: Boolean(previousEncodePlan?.isFolder), + isSharedAudio: Boolean(previousEncodePlan?.isSharedAudio), + preEncodeItems: preservedPreItems, + postEncodeItems: preservedPostItems, + preEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.preEncodeScriptIds || []), + postEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.postEncodeScriptIds || []), + preEncodeChainIds: normalizeChainIdList(previousEncodePlan?.preEncodeChainIds || []), + postEncodeChainIds: normalizeChainIdList(previousEncodePlan?.postEncodeChainIds || []), + reviewConfirmed: false + }; + } // HandBrake title scan for DVD structures (VIDEO_TS directories / ISO images) const needsHandBrakeTitleScan = rawMedia.source === 'dvd' @@ -13188,6 +13651,10 @@ class PipelineService extends EventEmitter { makemkvInfo: sourceMakemkvInfo, encodePlan: sourceEncodePlan }); + const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); + const preservedRetryMediaType = explicitSourceMediaType || ( + ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null + ); const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; @@ -13399,6 +13866,7 @@ class PipelineService extends EventEmitter { const retryUpdatePayload = { parent_job_id: Number(jobId), + media_type: preservedRetryMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, @@ -13436,6 +13904,9 @@ class PipelineService extends EventEmitter { 'USER_ACTION', `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).` ); + if (preservedRetryMediaType && !explicitSourceMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); + } this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); await historyService.retireJobInFavorOf(jobId, retryJobId, { reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry') @@ -13632,6 +14103,17 @@ class PipelineService extends EventEmitter { error.statusCode = 400; throw error; } + const restartMakemkvInfo = this.safeParseJson(job.makemkv_info_json); + const explicitJobMediaType = String(job?.media_type || '').trim().toLowerCase(); + const derivedRestartMediaProfile = this.resolveMediaProfileForJob(job, { + makemkvInfo: restartMakemkvInfo, + encodePlan + }); + const preservedRestartMediaType = explicitJobMediaType || ( + ['converter', 'cd', 'audiobook'].includes(derivedRestartMediaProfile) + ? derivedRestartMediaProfile + : null + ); const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); @@ -13770,6 +14252,7 @@ class PipelineService extends EventEmitter { await historyService.updateJob(replacementJobId, { parent_job_id: Number(jobId), + media_type: preservedRestartMediaType, title: job.title || null, year: job.year ?? null, imdb_id: job.imdb_id || null, @@ -13817,6 +14300,9 @@ class PipelineService extends EventEmitter { restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`; } await historyService.appendLog(replacementJobId, 'USER_ACTION', restartLogMessage); + if (preservedRestartMediaType && !explicitJobMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); + } await historyService.retireJobInFavorOf(jobId, replacementJobId, { reason: 'restart_encode' }); @@ -13880,6 +14366,10 @@ class PipelineService extends EventEmitter { encodePlan: reviewEncodePlan, rawPath: sourceJob.raw_path }); + const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); + const preservedReviewMediaType = explicitSourceMediaType || ( + ['converter', 'cd', 'audiobook'].includes(reviewMediaProfile) ? reviewMediaProfile : null + ); const reviewSettings = await settingsService.getSettingsMap(); let resolvedReviewRawPath = this.resolveCurrentRawPathForSettings( reviewSettings, @@ -14060,6 +14550,7 @@ class PipelineService extends EventEmitter { await historyService.resetProcessLog(targetJobId); await historyService.updateJob(targetJobId, { ...jobUpdatePayload, + media_type: preservedReviewMediaType, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, @@ -14094,6 +14585,7 @@ class PipelineService extends EventEmitter { await historyService.updateJob(replacementJobId, { parent_job_id: Number(jobId), + media_type: preservedReviewMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, @@ -14144,6 +14636,9 @@ class PipelineService extends EventEmitter { 'USER_ACTION', `Review-Neustart aus RAW angefordert.${reuseCurrentJob ? ' Bestehende Job-ID wird weiterverwendet.' : ''}${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.` ); + if (!reuseCurrentJob && preservedReviewMediaType && !explicitSourceMediaType) { + await historyService.updateJob(jobId, { media_type: preservedReviewMediaType }).catch(() => {}); + } if (!reuseCurrentJob) { await historyService.retireJobInFavorOf(jobId, targetJobId, { reason: 'restart_review' @@ -14691,16 +15186,20 @@ class PipelineService extends EventEmitter { hasRawPath = false; } - if (normalizedStage === 'ENCODING' && hasConfirmedPlan && !isCancelled) { + const shouldAutoRecoverEncode = normalizedStage === 'ENCODING' + && hasConfirmedPlan + && (!isCancelled || resolvedMediaProfile === 'converter'); + if (shouldAutoRecoverEncode) { try { + const recoveryReasonLabel = isCancelled ? 'Abbruch' : 'Fehler'; await historyService.appendLog( jobId, 'SYSTEM', - `Fehler in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` + `${recoveryReasonLabel} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` ); await this.restartEncodeWithLastSettings(jobId, { immediate: true, - triggerReason: 'failed_encode' + triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode' }); this.cancelRequestedByJob.delete(Number(jobId)); return; @@ -15810,10 +16309,15 @@ class PipelineService extends EventEmitter { async startConverterEncode(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { + const queuedJob = options?.preloadedJob || await historyService.getJobById(jobId); + const queuedPlan = this.safeParseJson(queuedJob?.encode_plan_json) || {}; + const queuedConverterMediaType = String(queuedPlan?.converterMediaType || '').trim().toLowerCase(); + const queuePoolType = queuedConverterMediaType === 'audio' ? 'audio' : 'film'; return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, - () => this.startConverterEncode(jobId, { ...options, immediate: true }) + () => this.startConverterEncode(jobId, { ...options, immediate: true }), + { poolType: queuePoolType } ); } @@ -15837,8 +16341,61 @@ class PipelineService extends EventEmitter { throw error; } - const outputPath = encodePlan.outputPath || null; - const outputDir = encodePlan.outputDir || null; + let outputPath = encodePlan.outputPath || null; + let outputDir = encodePlan.outputDir || null; + let outputFormat = String(encodePlan.outputFormat || '').trim().toLowerCase() || null; + + if (!outputPath && !outputDir) { + const converterSettings = await settingsService.getSettingsMap(); + const converterMediaType = String(encodePlan.converterMediaType || 'video').trim().toLowerCase(); + outputFormat = outputFormat || (converterMediaType === 'audio' ? 'flac' : 'mkv'); + + const parseTemplateSegments = (template) => String(template || '') + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((segment) => String(segment || '').trim()) + .filter(Boolean); + const renderTemplateSegment = (segment, values, fallback = 'unknown') => { + const rendered = renderTemplate(segment, values); + return sanitizeFileName(rendered) || fallback; + }; + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + const movieDir = String( + converterSettings?.converter_movie_dir || require('../config').defaultConverterMovieDir || '' + ).trim(); + const baseName = sanitizeFileName(path.basename(inputPath, path.extname(inputPath))) || 'output'; + const title = String( + encodePlan?.metadata?.title || job.title || job.detected_title || baseName + ).trim() || baseName; + const safeTitle = sanitizeFileName(title) || baseName; + const yearValue = Number.isFinite(Number(encodePlan?.metadata?.year)) + ? Math.trunc(Number(encodePlan.metadata.year)) + : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear()); + const videoTemplateRaw = String( + converterSettings?.converter_output_template_video || '{title}' + ).trim() || '{title}'; + const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw); + const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}']) + .map((segment) => renderTemplateSegment(segment, { + title: safeTitle, + year: yearValue + }, safeTitle)); + const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle; + const videoFolders = videoSegments.slice(0, -1); + outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`); + } + + encodePlan.outputFormat = outputFormat; + encodePlan.outputPath = outputPath || null; + encodePlan.outputDir = outputDir || null; + await historyService.updateJob(jobId, { + encode_plan_json: JSON.stringify(encodePlan), + output_path: outputPath || outputDir || null + }); + } await historyService.resetProcessLog(jobId); await this.setState('ANALYZING', { @@ -15867,6 +16424,15 @@ class PipelineService extends EventEmitter { try { const { ConverterPlugin } = require('../plugins/ConverterPlugin'); const converterPlugin = new ConverterPlugin(); + const appendConverterProcessLine = (source, line, stream = 'stderr') => { + const normalizedSource = String(source || '').trim().toUpperCase() || 'PROCESS'; + const rawLine = String(line || '').trim(); + if (!rawLine) { + return; + } + const streamPrefix = stream === 'stdout' ? '[OUT] ' : ''; + void historyService.appendLog(jobId, normalizedSource, `${streamPrefix}${rawLine}`); + }; const analyzeCtx = await this.buildPluginContext('converter', { jobId, @@ -15874,7 +16440,8 @@ class PipelineService extends EventEmitter { encodePlan, emitProgress: (pct, statusText) => { void this.updateProgress('ANALYZING', pct, null, statusText, jobId); - } + }, + appendLogLine: appendConverterProcessLine }); await converterPlugin.analyze(inputPath, job, analyzeCtx); @@ -15927,9 +16494,10 @@ class PipelineService extends EventEmitter { this.activeProcesses.set(Number(jobId), handle); } }, - emitProgress: (pct, statusText) => { - void this.updateProgress('ENCODING', pct, null, statusText, jobId); - } + emitProgress: (pct, statusText, eta) => { + void this.updateProgress('ENCODING', pct, eta ?? null, statusText, jobId); + }, + appendLogLine: appendConverterProcessLine }); await converterPlugin.encode(job, encodeCtx); @@ -17530,9 +18098,11 @@ class PipelineService extends EventEmitter { } } + const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; + const initialStatus = 'READY_TO_START'; const job = await historyService.createJob({ discDevice: null, - status: 'READY_TO_START', + status: initialStatus, detectedTitle }); @@ -17550,7 +18120,7 @@ class PipelineService extends EventEmitter { : null : null }), - last_state: 'READY_TO_START' + last_state: initialStatus }); // Scan-Eintrag als Job markieren @@ -17559,8 +18129,15 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - `Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType}` + `Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType || '-'}` ); + if (isVideoLikeConverterJob) { + await historyService.appendLog( + job.id, + 'SYSTEM', + 'Metadaten sind optional. Review kann direkt gestartet werden; OMDb kann später zugeordnet werden.' + ); + } logger.info('converter:job:created-from-entry', { jobId: job.id, relPath, converterMediaType, fullPath @@ -18200,8 +18777,10 @@ class PipelineService extends EventEmitter { error.statusCode = 409; throw error; } - if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') { - const error = new Error('Draft kann nur für nicht gestartete Converter-Jobs gespeichert werden.'); + const statusUpper = String(job.status || '').trim().toUpperCase(); + const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']); + if (!allowedDraftStates.has(statusUpper)) { + const error = new Error('Draft kann nur für nicht gestartete oder geprüfte Converter-Jobs gespeichert werden.'); error.statusCode = 409; throw error; } @@ -18588,25 +19167,55 @@ class PipelineService extends EventEmitter { postEncodeScriptIds, preEncodeChainIds, postEncodeChainIds, - reviewConfirmed: true + reviewConfirmed: converterMediaType === 'audio' }; - await historyService.updateJob(normalizedJobId, { + const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; + const metadataTitle = String(metadata?.title || '').trim() || null; + const metadataYear = Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null; + const metadataImdbId = String(metadata?.imdbId || '').trim() || null; + const metadataPoster = String(metadata?.poster || '').trim() || null; + const selectedFromOmdb = metadataImdbId ? 1 : 0; + + const jobPatch = { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', output_path: outputPath || outputDir || null, encode_plan_json: JSON.stringify(nextEncodePlan), - encode_review_confirmed: 1, + encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0, error_message: null, end_time: null - }); + }; + if (isVideoLikeConverterJob) { + if (metadataTitle) { + jobPatch.title = metadataTitle; + } + if (metadataYear !== null) { + jobPatch.year = metadataYear; + } + if (metadataImdbId) { + jobPatch.imdb_id = metadataImdbId; + } + if (metadataPoster) { + jobPatch.poster_url = metadataPoster; + } + jobPatch.selected_from_omdb = selectedFromOmdb; + } + await historyService.updateJob(normalizedJobId, jobPatch); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}` ); + if (isVideoLikeConverterJob) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'Starte Mediainfo-Review mit Titel-/Spurauswahl (analog Rip-Workflow).' + ); + } const startResult = await this.startPreparedJob(normalizedJobId); return { @@ -18621,18 +19230,49 @@ class PipelineService extends EventEmitter { async getConverterJobs() { const db = await require('../db/database').getDb(); const rows = await db.all(` - SELECT id, title, detected_title, status, last_state, media_type, - encode_plan_json, output_path, raw_path, error_message, + SELECT id, title, detected_title, year, imdb_id, poster_url, + status, last_state, media_type, encode_review_confirmed, + encode_plan_json, output_path, raw_path, encode_input_path, error_message, start_time, end_time, created_at, updated_at FROM jobs WHERE media_type = 'converter' + OR ( + (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') + AND ( + encode_plan_json LIKE '%"mediaProfile":"converter"%' + OR encode_plan_json LIKE '%"converterMediaType"%' + OR raw_path LIKE '%/converter/%' + OR encode_input_path LIKE '%/converter/%' + ) + ) ORDER BY created_at DESC LIMIT 200 `); - return rows.map((r) => ({ - ...r, - encodePlan: this.safeParseJson(r.encode_plan_json) - })); + return rows.map((r) => { + const encodePlan = this.safeParseJson(r.encode_plan_json); + const rawMediaType = String(r?.media_type || '').trim().toLowerCase(); + const converterHintFromPlan = ( + String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter' + || ['video', 'audio', 'iso'].includes(String(encodePlan?.converterMediaType || '').trim().toLowerCase()) + ); + const converterHintFromPath = ( + hasConverterPathSegment(r?.raw_path) + || hasConverterPathSegment(r?.encode_input_path) + || hasConverterPathSegment(encodePlan?.inputPath) + ); + const effectiveMediaType = rawMediaType === 'converter' + ? 'converter' + : ( + (converterHintFromPlan || converterHintFromPath) + ? 'converter' + : (rawMediaType || null) + ); + return { + ...r, + media_type: effectiveMediaType, + encodePlan + }; + }); } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc65bde..c311c26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-17", + "version": "0.12.0-18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-17", + "version": "0.12.0-18", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index cac3071..9fd3379 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-17", + "version": "0.12.0-18", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index abd40bb..2d5f1b5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; +import { ConfirmDialog } from 'primereact/confirmdialog'; import { api } from './api/client'; import { useWebSocket } from './hooks/useWebSocket'; import DashboardPage from './pages/DashboardPage'; @@ -548,6 +549,7 @@ function App() { return (
+
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index bc59dec..070a9fc 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -77,6 +77,45 @@ function afterMutationInvalidate(prefixes = []) { invalidateCachedGet(prefixes); } +function buildHandBrakePresetList(payload) { + const existing = Array.isArray(payload?.presets) ? payload.presets : []; + if (existing.length > 0) { + return existing; + } + const options = Array.isArray(payload?.options) ? payload.options : []; + const list = []; + for (const option of options) { + if (!option || option.disabled) { + continue; + } + const rawValue = option.value ?? option.name ?? option.label ?? ''; + const name = String(rawValue || '').trim(); + if (!name || name.startsWith('__group__')) { + continue; + } + const category = option.category ? String(option.category).trim() : ''; + list.push({ + name, + category: category || null + }); + } + return list; +} + +function normalizeHandBrakePresetPayload(payload) { + if (!payload || typeof payload !== 'object') { + return payload; + } + const existing = Array.isArray(payload.presets) ? payload.presets : []; + if (existing.length > 0) { + return payload; + } + return { + ...payload, + presets: buildHandBrakePresetList(payload) + }; +} + async function request(path, options = {}) { const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData; const mergedHeaders = { @@ -324,11 +363,12 @@ export const api = { getDetectedDrives() { return request('/settings/drives'); }, - getHandBrakePresets(options = {}) { - return requestCachedGet('/settings/handbrake-presets', { + async getHandBrakePresets(options = {}) { + const result = await requestCachedGet('/settings/handbrake-presets', { ttlMs: 10 * 60 * 1000, forceRefresh: options.forceRefresh }); + return normalizeHandBrakePresetPayload(result); }, getScripts(options = {}) { return requestCachedGet('/settings/scripts', { diff --git a/frontend/src/components/ConverterFileExplorer.jsx b/frontend/src/components/ConverterFileExplorer.jsx index 1f77d25..062797a 100644 --- a/frontend/src/components/ConverterFileExplorer.jsx +++ b/frontend/src/components/ConverterFileExplorer.jsx @@ -149,6 +149,7 @@ function collectFolderPaths(node, result = []) { export default function ConverterFileExplorer({ onSelectionChange, refreshToken, + selectionResetToken, navigateToPath, onAssignmentChanged }) { @@ -204,6 +205,12 @@ export default function ConverterFileExplorer({ useEffect(() => { loadTree(); }, [loadTree, refreshToken]); + useEffect(() => { + setSelectedPaths([]); + setActivePaths([]); + setActiveAnchor(null); + }, [selectionResetToken]); + // Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren // navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst useEffect(() => { @@ -217,6 +224,17 @@ export default function ConverterFileExplorer({ // Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert useEffect(() => { if (!tree) return; + setSelectedPaths((prev) => { + const next = prev.filter((entryPath) => { + const node = getNodeByPath(tree, entryPath); + if (!node) return false; + if (node.type !== 'file') return true; + const assignedJobId = Number(node.jobId); + return !Number.isFinite(assignedJobId) || assignedJobId <= 0; + }); + return next.length === prev.length ? prev : next; + }); + const rootPaths = getRootSelections(selectedPaths); const report = []; for (const p of rootPaths) { diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx index 1a3cd2a..f3769d0 100644 --- a/frontend/src/components/ConverterJobCard.jsx +++ b/frontend/src/components/ConverterJobCard.jsx @@ -13,6 +13,7 @@ import MetadataSelectionDialog from './MetadataSelectionDialog'; import CdMetadataDialog from './CdMetadataDialog'; import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; +import { confirmModal } from '../utils/confirmModal'; const VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, @@ -1129,7 +1130,14 @@ export default function ConverterJobCard({ const confirmText = statusUpper === 'READY_TO_START' ? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.` : `${jobIdLabel} wirklich abbrechen?`; - if (!window.confirm(confirmText)) return; + const confirmed = await confirmModal({ + header: 'Job abbrechen', + message: confirmText, + acceptLabel: 'Abbrechen', + rejectLabel: 'Zurück', + danger: true + }); + if (!confirmed) return; setCancelBusy(true); try { await api.cancelConverterJob(job.id); diff --git a/frontend/src/components/CronJobsTab.jsx b/frontend/src/components/CronJobsTab.jsx index c53ff2d..1917944 100644 --- a/frontend/src/components/CronJobsTab.jsx +++ b/frontend/src/components/CronJobsTab.jsx @@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown'; import { InputSwitch } from 'primereact/inputswitch'; import { Toast } from 'primereact/toast'; import { api } from '../api/client'; +import { confirmModal } from '../utils/confirmModal'; // ── Hilfsfunktionen ────────────────────────────────────────────────────────── @@ -251,7 +252,14 @@ export default function CronJobsTab({ onWsMessage }) { // ── Löschen ────────────────────────────────────────────────────────────────── async function handleDelete(job) { - if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return; + const confirmed = await confirmModal({ + header: 'Cronjob löschen', + message: `Cronjob "${job.name}" wirklich löschen?`, + acceptLabel: 'Löschen', + rejectLabel: 'Abbrechen', + danger: true + }); + if (!confirmed) return; setBusyId(job.id); try { await api.deleteCronJob(job.id); diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index c0166c7..82b9154 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -1217,7 +1217,8 @@ export default function MediaInfoReviewPanel({ onReorderPostEncodeItem = null, userPresets = [], selectedUserPresetId = null, - onUserPresetChange = null + onUserPresetChange = null, + selectedHandBrakePreset = '' }) { if (!review) { return

Keine Mediainfo-Daten vorhanden.

; @@ -1237,14 +1238,20 @@ export default function MediaInfoReviewPanel({ const selectedUserPreset = selectedUserPresetId ? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null : null; - const effectivePresetOverride = selectedUserPreset + const hasUserPresets = normalizedUserPresets.length > 0; + const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection; + const isUserPresetActive = Boolean(selectedUserPreset); + const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); + const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset); + const effectivePresetOverride = isUserPresetActive ? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' } - : null; + : (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null); const effectiveAudioSelector = effectivePresetOverride?.extraArgs ? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio) : (review?.selectors?.audio || null); - const hasUserPresets = normalizedUserPresets.length > 0; - const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection; + const presetSourceLabel = isUserPresetActive + ? `User-Preset: ${selectedUserPreset?.name || 'Preset'}` + : (isHandBrakePresetActive ? 'HandBrake-Preset' : ''); const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : []) .map((item) => ({ @@ -1308,23 +1315,28 @@ export default function MediaInfoReviewPanel({ )}
)} -
Preset:{' '} {effectivePresetOverride ? (effectivePresetOverride.handbrakePreset || '(kein Preset)') : presetLabel} - {effectivePresetOverride && (User-Preset: {selectedUserPreset?.name})} + {effectivePresetOverride && presetSourceLabel && ( + + ({presetSourceLabel}) + + )}
Extra Args:{' '} {effectivePresetOverride ? (effectivePresetOverride.extraArgs || '(keine)') : (review.selectors?.extraArgs || '(keine)')} - {effectivePresetOverride && !selectedUserPreset?.extraArgs && (aus User-Preset)} + {effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && ( + (aus User-Preset) + )}
-
Preset-Profil: {effectivePresetOverride ? 'user-preset' : (review.selectors?.presetProfileSource || '-')}
+
Preset-Profil: {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}
MIN_LENGTH_MINUTES: {review.minLengthMinutes}
Encode Input: {encodeInputTitle?.fileName || '-'}
Audio Auswahl: {effectiveAudioSelector?.mode || '-'}
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index a5252d0..e99f629 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -1,12 +1,19 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Card } from 'primereact/card'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Button } from 'primereact/button'; +import { Dropdown } from 'primereact/dropdown'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; import { api } from '../api/client'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; +const CONVERTER_VIDEO_OUTPUT_FORMATS = [ + { label: 'MKV', value: 'mkv' }, + { label: 'MP4', value: 'mp4' }, + { label: 'M4V', value: 'm4v' } +]; + function normalizeTitleId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -120,6 +127,9 @@ function normalizeMediaProfile(value) { if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { return 'audiobook'; } + if (raw === 'converter') { + return 'converter'; + } if (['other', 'sonstiges', 'unknown'].includes(raw)) { return 'other'; } @@ -453,6 +463,37 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId return `${root}/${baseName}.${ext}`; } +function buildConverterOutputPathPreview(settings, review, metadata, fallbackJobId = null) { + const explicitPath = String(review?.outputPath || review?.outputDir || '').trim(); + if (explicitPath) { + return explicitPath; + } + + const movieDir = String(settings?.converter_movie_dir || '').trim(); + if (!movieDir) { + return null; + } + const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); + const year = metadata?.year || new Date().getFullYear(); + const template = String(settings?.converter_output_template_video || '{title}').trim() || '{title}'; + const rendered = renderTemplate(template, { title, year }); + const segments = rendered + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((seg) => sanitizeFileName(seg)) + .filter(Boolean); + const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; + const folderParts = segments.slice(0, -1); + const ext = String(review?.outputFormat || 'mkv').trim() || 'mkv'; + const root = movieDir.replace(/\/+$/g, ''); + if (folderParts.length > 0) { + return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; + } + return `${root}/${baseName}.${ext}`; +} + export default function PipelineStatusCard({ jobId = null, pipeline, @@ -470,6 +511,7 @@ export default function PipelineStatusCard({ onCancel, onRetry, onDeleteJob, + onInputsChanged, isQueued = false, busy }) { @@ -502,6 +544,13 @@ export default function PipelineStatusCard({ const [postEncodeItems, setPostEncodeItems] = useState([]); const [userPresets, setUserPresets] = useState([]); const [selectedUserPresetId, setSelectedUserPresetId] = useState(null); + const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]); + const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState(''); + const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv'); + const [converterConfigSaving, setConverterConfigSaving] = useState(false); + const [converterConfigError, setConverterConfigError] = useState(null); + const converterConfigLatestRef = useRef(null); + const converterConfigSaveTimeoutRef = useRef(null); useEffect(() => { let cancelled = false; @@ -512,7 +561,7 @@ export default function PipelineStatusCard({ api.getHandBrakePresets(), api.getScripts(), api.getScriptChains(), - api.getUserPresets() + api.getUserPresets(null, { forceRefresh: true }) ]); if (!cancelled) { const categories = settingsResponse.status === 'fulfilled' @@ -523,6 +572,7 @@ export default function PipelineStatusCard({ ? (presetsResponse.value?.options || []) : []; setPresetDisplayMap(buildPresetDisplayMap(presetOptions)); + setHandBrakePresetOptions(Array.isArray(presetOptions) ? presetOptions : []); const scripts = scriptsResponse.status === 'fulfilled' ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) : []; @@ -548,6 +598,7 @@ export default function PipelineStatusCard({ setScriptCatalog([]); setChainCatalog([]); setUserPresets([]); + setHandBrakePresetOptions([]); } } }; @@ -570,14 +621,25 @@ export default function PipelineStatusCard({ ...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id })) ]); + const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv'; + setSelectedOutputFormat(nextOutputFormat); const userPresetId = Number(mediaInfoReview?.userPreset?.id); - setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null); + if (Number.isFinite(userPresetId) && userPresetId > 0) { + setSelectedUserPresetId(Math.trunc(userPresetId)); + setSelectedHandBrakePreset(''); + } else { + setSelectedUserPresetId(null); + const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim(); + setSelectedHandBrakePreset(hbPresetName); + } }, [ mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, mediaInfoReview?.reviewConfirmedAt, mediaInfoReview?.prefilledFromPreviousRunAt, + mediaInfoReview?.outputFormat, mediaInfoReview?.userPreset?.id, + mediaInfoReview?.userPreset?.handbrakePreset, retryJobId ]); @@ -609,6 +671,11 @@ export default function PipelineStatusCard({ const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired); const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId)); const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; + const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); + const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset); + const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video'; + const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio'; + const requiresPresetSelection = showConverterConfig; // Filter user presets by job media profile ('all' presets always shown) const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => { @@ -616,11 +683,17 @@ export default function PipelineStatusCard({ if (!jobMediaProfile) { return true; } + // Converter jobs may reuse any user preset; do not restrict by preset media type here. + if (jobMediaProfile === 'converter') { + return true; + } return presetMediaType === 'all' || presetMediaType === jobMediaProfile; }); - const canStartReadyJob = isPreRipReview + const effectiveUserPresets = filteredUserPresets; + const canStartReadyJob = (isPreRipReview ? Boolean(retryJobId) - : Boolean(retryJobId && encodeInputPath); + : Boolean(retryJobId && encodeInputPath)) + && (!requiresPresetSelection || hasSelectedPreset); const canRestartEncodeFromLastSettings = Boolean( (state === 'ERROR' || state === 'CANCELLED') && retryJobId @@ -631,6 +704,87 @@ export default function PipelineStatusCard({ && !running && (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath) ); + const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE'; + + const converterConfigPayload = useMemo(() => { + if (!showConverterConfig) { + return null; + } + let userPreset = null; + if (selectedUserPresetId) { + const preset = (Array.isArray(userPresets) ? userPresets : []) + .find((p) => Number(p?.id) === Number(selectedUserPresetId)); + userPreset = preset + ? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' } + : { id: selectedUserPresetId }; + } else if (trimmedHandBrakePreset) { + userPreset = { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' }; + } + return { + converterMediaType, + outputFormat: selectedOutputFormat, + userPreset + }; + }, [ + showConverterConfig, + converterMediaType, + selectedOutputFormat, + selectedUserPresetId, + trimmedHandBrakePreset, + userPresets + ]); + + const converterConfigSerialized = useMemo( + () => (converterConfigPayload ? JSON.stringify(converterConfigPayload) : null), + [converterConfigPayload] + ); + + useEffect(() => { + converterConfigLatestRef.current = null; + setConverterConfigError(null); + setConverterConfigSaving(false); + }, [retryJobId]); + + useEffect(() => { + if (!allowConverterConfigEdit || !converterConfigPayload || !retryJobId) { + return; + } + if (converterConfigLatestRef.current === null) { + converterConfigLatestRef.current = converterConfigSerialized; + return; + } + if (converterConfigSerialized === converterConfigLatestRef.current) { + return; + } + if (converterConfigSaveTimeoutRef.current) { + clearTimeout(converterConfigSaveTimeoutRef.current); + } + converterConfigSaveTimeoutRef.current = setTimeout(async () => { + setConverterConfigSaving(true); + setConverterConfigError(null); + try { + await api.converterUpdateJobConfig(retryJobId, converterConfigPayload); + converterConfigLatestRef.current = converterConfigSerialized; + onInputsChanged?.(retryJobId, converterConfigPayload); + } catch (error) { + setConverterConfigError(error?.message || 'Konfiguration konnte nicht gespeichert werden.'); + } finally { + setConverterConfigSaving(false); + } + }, 650); + return () => { + if (converterConfigSaveTimeoutRef.current) { + clearTimeout(converterConfigSaveTimeoutRef.current); + converterConfigSaveTimeoutRef.current = null; + } + }; + }, [ + allowConverterConfigEdit, + converterConfigPayload, + converterConfigSerialized, + retryJobId, + onInputsChanged + ]); const waitingPlaylistRows = useMemo(() => { const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) @@ -780,10 +934,15 @@ export default function PipelineStatusCard({ }, [running, retryJobId]); const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired; - const commandOutputPath = useMemo( - () => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId), - [settingsMap, jobMediaProfile, selectedMetadata, retryJobId] - ); + const commandOutputPath = useMemo(() => { + if (jobMediaProfile === 'converter') { + const reviewWithOutput = mediaInfoReview + ? { ...mediaInfoReview, outputFormat: selectedOutputFormat } + : { outputFormat: selectedOutputFormat }; + return buildConverterOutputPathPreview(settingsMap, reviewWithOutput, selectedMetadata, retryJobId); + } + return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId); + }, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat]); const presetDisplayValue = useMemo(() => { const preset = String(mediaInfoReview?.selectors?.preset || '').trim(); if (!preset) { @@ -791,6 +950,52 @@ export default function PipelineStatusCard({ } return presetDisplayMap[preset] || preset; }, [mediaInfoReview?.selectors?.preset, presetDisplayMap]); + const converterUserPresetOptions = useMemo(() => { + if (!showConverterConfig) { + return []; + } + const userOptions = (Array.isArray(effectiveUserPresets) ? effectiveUserPresets : []) + .map((preset) => ({ + label: String(preset?.name || '').trim(), + value: Number(preset?.id) + })) + .filter((option) => option.label && Number.isFinite(option.value)); + return [{ label: 'Kein User-Preset', value: null }, ...userOptions]; + }, [showConverterConfig, effectiveUserPresets]); + const converterHandBrakePresetOptions = useMemo(() => { + if (!showConverterConfig) { + return []; + } + const list = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : []; + const hbOptions = list + .map((option) => { + const label = String(option?.label || '').trim(); + if (!label) { + return null; + } + const disabled = Boolean(option?.disabled); + const lowerLabel = label.toLowerCase(); + if (lowerLabel.includes('handbrake cli') || lowerLabel.includes('cli default')) { + return null; + } + if (!disabled && (lowerLabel.includes('kein preset') || lowerLabel.includes('cli-parameter'))) { + return null; + } + const value = disabled + ? String(option?.value || label) + : String(option?.value || '').trim(); + if (!disabled && !value) { + return null; + } + return { + label, + value, + disabled + }; + }) + .filter(Boolean); + return [{ label: 'Kein HandBrake-Preset', value: '' }, ...hbOptions]; + }, [showConverterConfig, handBrakePresetOptions]); const buildSelectedTrackSelectionForCurrentTitle = () => { const encodeTitleId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) @@ -862,7 +1067,8 @@ export default function PipelineStatusCard({ selectedPostScriptIds, selectedPreScriptIds, selectedPostChainIds, - selectedPreChainIds + selectedPreChainIds, + selectedHandBrakePreset: trimmedHandBrakePreset }; }; @@ -908,7 +1114,10 @@ export default function PipelineStatusCard({ /> )} - {(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') && retryJobId && typeof onOpenMetadata === 'function' ? ( + {(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') + && retryJobId + && typeof onOpenMetadata === 'function' + && !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (