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) ? (
) : null}
- {!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
+ {state === 'METADATA_SELECTION' && jobMediaProfile === 'converter' && retryJobId ? (
+
+
+
Idle
+ {queueIdleJobs.length === 0 ? (
+
Keine Idle-Jobs.
+ ) : (
+ queueIdleJobs.map((item) => {
+ const hasScriptSummary = hasQueueScriptSummary(item);
+ const detailKey = buildIdleQueueScriptKey(item?.jobId);
+ const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
+ return (
+
+
+
+
+ #{item.jobId} | {item.title || `Job #${item.jobId}`}
+ {item.hasScripts ? : null}
+ {item.hasChains ? : null}
+
+ {getStatusLabel(item.status)}
+
+ {hasScriptSummary ? (
+
toggleQueueScriptDetails(detailKey)}
+ >
+
+
+ ) : null}
+
+ {detailsExpanded ?
: null}
+
+ );
+ })
+ )}
+
Warteschlange
diff --git a/frontend/src/pages/DatabasePage.jsx b/frontend/src/pages/DatabasePage.jsx
index d323ec2..b5811b8 100644
--- a/frontend/src/pages/DatabasePage.jsx
+++ b/frontend/src/pages/DatabasePage.jsx
@@ -6,6 +6,7 @@ import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
+import { confirmModal } from '../utils/confirmModal';
function formatDetectedMediaType(value) {
const mediaType = String(value || '').trim().toLowerCase();
@@ -48,7 +49,12 @@ export default function DatabasePage() {
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
- const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
+ const confirmed = await confirmModal({
+ header: 'Historienjob anlegen',
+ message: `Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`,
+ acceptLabel: 'Job anlegen',
+ rejectLabel: 'Abbrechen'
+ });
if (!confirmed) {
return;
}
diff --git a/frontend/src/pages/DownloadsPage.jsx b/frontend/src/pages/DownloadsPage.jsx
index 4891da8..9b5b79e 100644
--- a/frontend/src/pages/DownloadsPage.jsx
+++ b/frontend/src/pages/DownloadsPage.jsx
@@ -8,6 +8,7 @@ import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
+import { confirmModal } from '../utils/confirmModal';
const STATUS_OPTIONS = [
{ label: 'Alle Stati', value: '' },
@@ -164,7 +165,13 @@ export default function DownloadsPage({ refreshToken = 0 }) {
return;
}
const label = row?.archiveName || `ZIP ${id}`;
- const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
+ const confirmed = await confirmModal({
+ header: 'ZIP löschen',
+ message: `"${label}" wirklich loeschen?`,
+ acceptLabel: 'Löschen',
+ rejectLabel: 'Abbrechen',
+ danger: true
+ });
if (!confirmed) {
return;
}
diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx
index 0736735..d188728 100644
--- a/frontend/src/pages/HistoryPage.jsx
+++ b/frontend/src/pages/HistoryPage.jsx
@@ -22,6 +22,7 @@ import {
normalizeStatus,
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
+import { confirmModal } from '../utils/confirmModal';
const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
@@ -274,25 +275,50 @@ function resolveAudiobookDetails(row) {
};
}
-function getOutputLabelForRow(row) {
- const mediaType = resolveMediaType(row);
- if (mediaType === 'cd') {
- return 'Audio-Dateien';
+function resolveConverterMediaType(row) {
+ const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
+ const value = String(
+ encodePlan?.converterMediaType
+ || row?.converterMediaType
+ || ''
+ ).trim().toLowerCase();
+ if (value === 'audio') {
+ return 'audio';
}
- if (mediaType === 'audiobook') {
+ if (value === 'video' || value === 'iso') {
+ return 'video';
+ }
+ return null;
+}
+
+function hasAudioOutput(row) {
+ const mediaType = resolveMediaType(row);
+ if (mediaType === 'cd' || mediaType === 'audiobook') {
+ return true;
+ }
+ if (mediaType === 'converter') {
+ return resolveConverterMediaType(row) === 'audio';
+ }
+ return false;
+}
+
+function getOutputLabelForRow(row) {
+ if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook-Datei(en)';
}
+ if (hasAudioOutput(row)) {
+ return 'Audio-Dateien';
+ }
return 'Movie-Datei(en)';
}
function getOutputShortLabelForRow(row) {
- const mediaType = resolveMediaType(row);
- if (mediaType === 'cd') {
- return 'Audio';
- }
- if (mediaType === 'audiobook') {
+ if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook';
}
+ if (hasAudioOutput(row)) {
+ return 'Audio';
+ }
return 'Movie';
}
@@ -717,7 +743,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
- const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
+ const confirmed = await confirmModal({
+ header: 'Review neu starten',
+ message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`,
+ acceptLabel: 'Review starten',
+ rejectLabel: 'Abbrechen'
+ });
if (!confirmed) {
return;
}
@@ -743,7 +774,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_cd_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
- const confirmed = window.confirm(`CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`);
+ const confirmed = await confirmModal({
+ header: 'CD-Vorprüfung starten',
+ message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`,
+ acceptLabel: 'Vorprüfung starten',
+ rejectLabel: 'Abbrechen'
+ });
if (!confirmed) {
return;
}
@@ -848,7 +884,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
const outputShortLabel = getOutputShortLabelForRow(row);
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
const title = row.title || row.detected_title || `Job #${row.id}`;
- const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
+ const confirmed = await confirmModal({
+ header: 'Dateien löschen',
+ message: `${label} für "${title}" wirklich löschen?`,
+ acceptLabel: 'Löschen',
+ rejectLabel: 'Abbrechen',
+ danger: true
+ });
if (!confirmed) {
return;
}
@@ -998,7 +1040,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
const mediaType = resolveMediaType(row);
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
- const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
+ const confirmed = await confirmModal({
+ header: `${actionLabel} neu starten`,
+ message: `${actionLabel} für "${title}" neu starten?`,
+ acceptLabel: 'Neu starten',
+ rejectLabel: 'Abbrechen'
+ });
if (!confirmed) {
return;
}
@@ -1415,6 +1462,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const listItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
+ const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
@@ -1460,8 +1508,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
- {renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
- {renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
+ {renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
+ {renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{renderSupplementalInfo(row)}
@@ -1476,6 +1524,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const gridItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
+ const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
@@ -1523,8 +1572,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
- {renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
- {renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
+ {renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
+ {renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{renderSupplementalInfo(row)}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index dcf893f..30ade96 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -11,6 +11,7 @@ import { InputSwitch } from 'primereact/inputswitch';
import { api } from '../api/client';
import DynamicSettingsForm from '../components/DynamicSettingsForm';
import CronJobsTab from '../components/CronJobsTab';
+import { confirmModal } from '../utils/confirmModal';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
@@ -689,7 +690,13 @@ export default function SettingsPage() {
if (!Number.isFinite(scriptId) || scriptId <= 0) {
return;
}
- const confirmed = window.confirm(`Script "${script?.name || scriptId}" wirklich löschen?`);
+ const confirmed = await confirmModal({
+ header: 'Script löschen',
+ message: `Script "${script?.name || scriptId}" wirklich löschen?`,
+ acceptLabel: 'Löschen',
+ rejectLabel: 'Abbrechen',
+ danger: true
+ });
if (!confirmed) {
return;
}
@@ -933,7 +940,14 @@ export default function SettingsPage() {
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
- if (!window.confirm(`Skriptkette "${chain?.name || chainId}" wirklich löschen?`)) {
+ const confirmed = await confirmModal({
+ header: 'Skriptkette löschen',
+ message: `Skriptkette "${chain?.name || chainId}" wirklich löschen?`,
+ acceptLabel: 'Löschen',
+ rejectLabel: 'Abbrechen',
+ danger: true
+ });
+ if (!confirmed) {
return;
}
try {
diff --git a/frontend/src/utils/confirmModal.js b/frontend/src/utils/confirmModal.js
new file mode 100644
index 0000000..3a9185e
--- /dev/null
+++ b/frontend/src/utils/confirmModal.js
@@ -0,0 +1,41 @@
+import { confirmDialog } from 'primereact/confirmdialog';
+
+export function confirmModal(options = {}) {
+ const message = String(options?.message || '').trim();
+ if (!message) {
+ return Promise.resolve(false);
+ }
+
+ const header = String(options?.header || 'Bitte bestätigen').trim() || 'Bitte bestätigen';
+ const acceptLabel = String(options?.acceptLabel || 'Ja').trim() || 'Ja';
+ const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
+ const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
+ const danger = Boolean(options?.danger);
+
+ return new Promise((resolve) => {
+ let finished = false;
+ const settle = (value) => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ resolve(Boolean(value));
+ };
+
+ confirmDialog({
+ header,
+ message,
+ icon,
+ closable: true,
+ closeOnEscape: true,
+ draggable: false,
+ dismissableMask: true,
+ acceptLabel,
+ rejectLabel,
+ ...(danger ? { acceptClassName: 'p-button-danger' } : {}),
+ accept: () => settle(true),
+ reject: () => settle(false),
+ onHide: () => settle(false)
+ });
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index 8ddabd5..bde99d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster",
- "version": "0.12.0-17",
+ "version": "0.12.0-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
- "version": "0.12.0-17",
+ "version": "0.12.0-18",
"devDependencies": {
"concurrently": "^9.1.2"
}
diff --git a/package.json b/package.json
index 1775abf..17c7c75 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
- "version": "0.12.0-17",
+ "version": "0.12.0-18",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",